fix(gateway): bound stalled upstream requests - #494
Open
tarkilhk wants to merge 1 commit into
Open
Conversation
Add configurable upstream connect and send/header deadlines, return a sanitized 504 on expiry, and rotate only the affected reqwest client generation so later requests escape suspect pooled state. Preserve streamed response bodies and avoid automatic request replay.\n\nRefs onecli#493.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I have read the CONTRIBUTING.md file.
YES
What kind of change does this PR introduce?
Bug fix / reliability proposal for #493.
This PR is intentionally presented as one possible implementation of the behavior requested in #493. It is not intended to prescribe this exact architecture: if the maintainers prefer a different timeout, error-mapping, or client-pool recovery design, the issue can be addressed independently of this proposal.
What is the current behavior?
Issue #493 documents an intermittent field failure in which the gateway's upstream forwarding path stopped completing requests to GitHub. The same OneCLI image had served the requests successfully before the incident. During the failure:
The initiating runtime fault could not be recovered after restart, so the report does not claim a proven cause. Cargo feature inspection also shows that this build's upstream reqwest client does not enable reqwest's HTTP/2 feature; the upstream pool is HTTP/1.1. A bad HTTP/1.1 keep-alive connection or other stuck outbound state remains plausible, but unproven.
The deterministic source-level defect is narrower: the gateway awaited upstream
RequestBuilder::send()without an explicit connect timeout, response-header/request-send deadline, or recovery mechanism for the shared reqwest client pool. A local upstream that accepted a request and never sent response headers could therefore keep the forwarding task pending indefinitely.Related but distinct work:
What is the new behavior?
This proposal adds two startup-parsed transport settings:
Missing values use those defaults. Zero, negative, or non-integer values fail startup and name the offending setting.
For each upstream TLS policy, the gateway owns a shared generation-managed reqwest client:
A request leases the current generation for its TLS-policy slot. The gateway then:
applies the configured reqwest connect timeout;
bounds
RequestBuilder::send()with the configured 120-second default;leaves the response body unbounded after response headers arrive;
on expiry, returns a static sanitized HTTP 504 response:
{ "error": "upstream_timeout", "message": "Upstream did not return response headers before the gateway timeout." }adds
x-should-retry: falseto prevent unsafe automatic replay;rotates the affected client generation if and only if the timing-out lease still references the current generation;
does not retry or replay the failed request.
Subsequent requests lease the fresh generation and cannot reuse the retired generation's pool. Concurrent requests timing out from one retired generation perform at most one rotation.
The MITM path now leases the current upstream client per inner request rather than freezing one reqwest client for the entire lifetime of a CONNECT tunnel. Long-lived tunnels can therefore observe a later generation after recovery.
Additional context
Why this design
Bound failure rather than changing protocols
The reproducible defect is the unbounded wait, not a proven TLS-version or HTTP/2 bug. This proposal therefore does not:
The OneCLI upstream reqwest client is already HTTP/1.1-only in this build. Pool rotation is still useful because it prevents future requests from leasing the same set of HTTP/1.1 keep-alive connections or other client-local state after a timeout.
Separate connection and send/header bounds
A connection timeout contains DNS/TCP/TLS establishment. The second bound contains a connection that accepts the request path but does not produce response headers.
The second setting is intentionally not a total response timeout. Once headers arrive, SSE, long downloads, and delayed/streamed response bodies continue without this deadline.
Important reqwest semantic:
RequestBuilder::send()covers request transmission as well as waiting for response headers. A slow streaming request upload therefore counts againstGATEWAY_UPSTREAM_RESPONSE_HEADER_TIMEOUT_SECS. This is disclosed in code and.env.example; it is the main compatibility trade-off in this proposal. reqwest does not expose a Go-style timer that begins strictly after the complete request body has been written, while reqwest's total timeout would incorrectly cap streamed response bodies.Rotate after timeout
A timeout alone bounds one task but does not prove that a client pool has forgotten the state associated with that request. Rebuilding the affected client slot guarantees that later requests lease a new pool generation.
Generation comparison keeps concurrent recovery idempotent:
The old client remains reference-counted for requests already using it; it is not destroyed underneath in-flight work.
Keep TLS policies independent
The existing standard and skip-verification clients had different security behavior. This patch keeps them in independent slots and records the certificate policy needed to rebuild each slot. A timeout in an explicitly no-verify destination does not alter or rotate the normal-verification slot, and vice versa.
GATEWAY_DANGER_ACCEPT_INVALID_CERTSretains its existing global behavior. WebSocket TLS connectors and raw tunnels are unchanged.Do not replay
The gateway cannot safely infer that a timed-out request was not accepted upstream. Automatically replaying a POST, streamed upload, or other non-idempotent operation could duplicate a side effect. The proposal therefore returns a controlled error, marks it no-automatic-retry, and leaves any explicit retry decision to the caller/application.
This deliberately sacrifices automatic GET recovery in favor of replay safety across all methods.
Known trade-offs and limitations
Observability and sanitization
The new timeout response is static and never echoes:
The new timeout event adds only:
Existing surrounding request spans may carry the gateway's normal request metadata; raw field-test logs are therefore not copied into this PR or #493. The issue and PR contain no credentials, agent/project identifiers, private endpoints, authorization headers, private CA material, or raw sensitive traces.
Regression tests added
Focused transport/config tests cover:
Focused forwarding tests use real local TCP listeners and cover:
upstream_timeout;Verification results
All commands below were run against the complete uncommitted proposal diff.
The fresh clone required the repository's documented generation step before monorepo type checking/building:
No production configuration was copied into the source clone.
Official-image build
The repository's official Dockerfile built successfully with the proposal:
End-to-end fault injection
A custom gateway from that image ran as an isolated sidecar on an alternate port. Production remained on its existing port and binary.
A disposable local HTTP server provided deterministic endpoints:
The sidecar used one-second test-only settings. Sanitized results:
The custom sidecar also exercised the existing GitHub connection paths without retaining response bodies or credentials:
The sidecar, fault server, and temporary binary were then stopped/removed. Production health remained HTTP 200 on both web and gateway health endpoints.
Post-test production controls
The proposal was not deployed to production. The currently released OneCLI process still passed:
These controls confirm the recovered production service remained healthy; they are not claimed as validation of deployed proposal behavior.
Independent review
A fresh read-only Fable 5 review of the complete diff returned
APPROVEwith no critical or blocking findings. Its non-blocking trade-offs are disclosed above: request-upload timing, per-slot cross-host pool rotation, pre-approval stale leases, and conservative no-retry behavior.Maintainer choice
The requested behavior in #493 is:
This PR is a tested proposal for those properties. The maintainers are explicitly invited to implement #493 differently, adjust the defaults/configuration surface, request a smaller first patch, or use this branch only as a reproducer/reference.