Skip to content

fix(upstream): name the real transport fault and bound the connection layer - #808

Merged
jarvis9443 merged 3 commits into
mainfrom
fix/transport-diagnostics-1122
Jul 23, 2026
Merged

fix(upstream): name the real transport fault and bound the connection layer#808
jarvis9443 merged 3 commits into
mainfrom
fix/transport-diagnostics-1122

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Two connection-layer gaps behind the intermittent upstream failures reported in AISIX-Cloud#1122.

1. Transport errors were undiagnosable

Every bridge built its error as BridgeError::Transport(e.to_string()). reqwest's top-level Display is only ever error sending request for url (…) — the identical string for a DNS failure, a refused connection, a TLS handshake error, and a pooled connection the far end already closed. The source() chain that names the actual fault was dropped.

Measured against a real reqwest::Error:

before: error sending request for url (http://host/v1/chat/completions)

after:  error sending request for url (http://host/v1/chat/completions):
        client error (Connect): tcp connect error: Connection refused (os error 111)

after:  error sending request for url (http://host/v1/chat/completions):
        client error (Connect): dns error: failed to lookup address information:
        Name or service not known

aisix_gateway::transport_error_message flattens the chain into the message and drops the tail cause hyper restates at several levels. Applied at all 21 Transport(...) construction sites across the openai / azure / anthropic / vertex bridges and the proxy's passthrough, audio, jobs, realtime, and shared dispatch helper.

Incidental credential fix

reqwest embeds the full URL in that message, and Vertex/Gemini accept ?key= / ?access_token= — which an operator can put straight into a ProviderKey api_base. The pre-existing single-line form could therefore echo a live key into a log. Credential-bearing query parameters are now redacted before the message is built; non-credential params (api-version, alt, …) stay readable because they're the diagnostic part.

2. Connection settings were reqwest's defaults

http_client.rs and all four provider default_client()s were a bare Client::builder().user_agent(...). That means no connect timeout, TCP keepalive off, and a 90s pooled-connection lifetime — longer than the idle timeout of a typical LB / NAT gateway / corporate proxy / service mesh hop. A connection reaped upstream is still handed out by the pool, and the request fails with exactly the opaque transport error above.

All of them now build through aisix_gateway::client_builder(), driven by a new upstream: config block:

knob default why
connect_timeout_ms 5000 a black-holed upstream was otherwise bounded only by the model's overall timeout
tcp_keepalive_secs 60 keeps a NAT/LB idle timer from reaping the connection while a slow model produces its first token
tcp_keepalive_interval_secs 30
tcp_keepalive_retries 5
pool_idle_timeout_secs 30 must stay below the shortest idle timeout on the path to the provider
pool_max_idle_per_host unset

0 disables any single knob. Settings are process-wide and installed at boot before any bridge builds its client, because the pools can't be reconfigured afterwards.

Baseline

LiteLLM solves the same problem the same way — it sets SO_KEEPALIVE explicitly on its upstream sockets, with the stated reason that NAT/LB hops reap the flow before a slow provider response arrives, and uses a 5s connect timeout with TCP_KEEPIDLE=60 / KEEPINTVL=30 / KEEPCNT=5. Our defaults match those; the pool-idle knob is ours (httpx expires idle connections after 5s by default, reqwest after 90s — 30s sits between them).

Scope note

Guardrail dispatchers (lakera, presidio, aliyun, …) build their own clients and are untouched here: they call guardrail services, not the LLM upstreams this issue is about. Same hardening applies to them and is worth a follow-up.

Tests

  • cause-chain flattening + dedup of a restated tail cause;
  • credential redaction, including case-insensitive parameter names, and a no-query URL left byte-identical;
  • config defaults with no upstream: block, and per-knob overrides with 0 round-tripping;
  • a real reqwest::Error against a closed port asserting reqwest's own Display hides the cause while the rendered message names it.

Full workspace suite passes.

Fixes api7/AISIX-Cloud#1122

Summary by CodeRabbit

  • New Features

    • Added configurable upstream connection settings, including timeouts, TCP keepalive, and connection-pool behavior.
    • Applied shared connection settings across provider, proxy, and streaming requests.
    • Improved transport error messages with underlying causes while redacting sensitive URL parameters.
  • Bug Fixes

    • Upstream connection failures now provide clearer, more actionable diagnostics.
    • Configuration defaults remain active when upstream settings are omitted.

… layer

Two connection-layer gaps behind the intermittent upstream failures in
AISIX-Cloud#1122.

**Transport errors were undiagnosable.** Every bridge built its error as
`BridgeError::Transport(e.to_string())`, and reqwest's top-level Display
is only ever "error sending request for url (…)" — the identical string
for a DNS failure, a refused connection, a TLS handshake error, and a
pooled connection the far end already closed. The `source()` chain that
names the actual fault was dropped on the floor:

    before: error sending request for url (http://host/v1/chat/completions)
    after:  error sending request for url (http://host/v1/chat/completions):
            client error (Connect): tcp connect error: Connection refused (os error 111)
    after:  … : client error (Connect): dns error: failed to lookup address
            information: Name or service not known

`aisix_gateway::transport_error_message` flattens the chain into the
message, deduplicating the tail cause hyper restates at several levels.
Credential-bearing query parameters are redacted first — Vertex/Gemini
accept `?key=` and `?access_token=`, and reqwest embeds the full URL in
the message, so the pre-existing single-line form could already echo a
live key into a log.

**Connection settings were reqwest's defaults.** No connect timeout, TCP
keepalive off, and a 90s pooled-connection lifetime that outlives the
idle timeout of a typical LB / NAT / proxy hop — so a connection reaped
upstream can still be handed out here and fails the request. All bridges
plus the proxy's shared client now build through
`aisix_gateway::client_builder()`, driven by a new `upstream:` config
block. Defaults follow LiteLLM's reasoning for the same problem (it sets
SO_KEEPALIVE explicitly, citing NAT idle timers reaping connections
while a slow model is still producing): connect 5s, keepalive 60s /
interval 30s / 5 retries, pool idle 30s. `0` disables any single knob.

Tests: cause-chain flattening and dedup, credential redaction (including
case-insensitive), config defaults + per-knob overrides, and a real
`reqwest::Error` against a closed port asserting the top-level Display
hides the cause while the rendered message names it.

Fixes api7/AISIX-Cloud#1122
@jarvis9443 jarvis9443 closed this Jul 23, 2026
@jarvis9443 jarvis9443 reopened this Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 32 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 14591aa2-f169-4ed7-b0d5-4cabd6c63a2b

📥 Commits

Reviewing files that changed from the base of the PR and between b10c3ec and b631d2e.

📒 Files selected for processing (15)
  • crates/aisix-gateway/src/bridge.rs
  • crates/aisix-gateway/src/upstream_http.rs
  • crates/aisix-provider-anthropic/src/bridge.rs
  • crates/aisix-provider-azure-openai/src/bridge.rs
  • crates/aisix-provider-bedrock/src/bridge.rs
  • crates/aisix-provider-openai/src/bridge.rs
  • crates/aisix-provider-vertex/src/bridge.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/cooldown.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/error.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/routing.rs
  • crates/aisix-proxy/src/stream_timeout.rs
📝 Walkthrough

Walkthrough

Changes

Upstream HTTP configuration

Layer / File(s) Summary
Configuration contract
config.example.yaml, config.managed.yaml, crates/aisix-core/src/config.rs
Adds defaulted upstream timeout, keepalive, pooling, and zero-to-disable settings with configuration-loading tests.
Gateway HTTP layer
crates/aisix-gateway/src/upstream_http.rs, crates/aisix-gateway/src/lib.rs
Adds shared client-builder configuration, process-wide initialization, URL redaction, and cause-chain transport error helpers with tests.
Server startup initialization
crates/aisix-server/src/main.rs
Maps core settings to gateway HTTP settings and initializes them before gateway execution.
Provider bridge adoption
crates/aisix-provider-*/src/bridge.rs
Provider bridges use the shared client builder and standardized transport error formatting for request and streaming failures.
Proxy client and error-path adoption
crates/aisix-proxy/src/*
The shared proxy client uses configured HTTP settings, and proxy transport failures use standardized or cause-aware messages.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Config
  participant Server
  participant GatewayHTTP
  participant ProviderBridge
  participant Reqwest
  Config->>Server: load cfg.upstream
  Server->>GatewayHTTP: initialize UpstreamHttpConfig
  ProviderBridge->>GatewayHTTP: request client_builder()
  GatewayHTTP->>Reqwest: apply timeout, keepalive, and pool settings
  Reqwest-->>ProviderBridge: response or transport error
  ProviderBridge->>GatewayHTTP: format transport error with causes
Loading

Suggested reviewers: moonming


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Security Check ❌ Error The URL redaction denylist misses aliases like client_secret, api-key, and x-amz-signature, and openai-style api_base preserves queries, so secrets can leak in transport logs. Expand SENSITIVE_QUERY_PARAMS to cover common aliases (e.g. client_secret, api-key, x-amz-signature) and add regression tests for each plus provider URLs that preserve queries.
E2e Test Quality Review ⚠️ Warning Tests are still unit/integration-only, the new reqwest error test hardcodes 127.0.0.1:1, and the redaction denylist still misses api-key/client_secret/x-amz-signature. Add true end-to-end coverage, switch the closed-port test to an ephemeral listener, and extend redaction tests/denylist for the missing credential aliases.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately reflects the PR’s two main changes: better transport-failure naming and upstream connection-layer configuration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/transport-diagnostics-1122

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-gateway/src/upstream_http.rs`:
- Around line 299-307: Update real_transport_error_names_the_root_cause to bind
a loopback TcpListener on an ephemeral port, capture its assigned address, drop
the listener, and construct the request URL from that address before sending.
Preserve the assertion that the resulting connection attempt fails without
relying on a fixed port.
- Around line 25-35: Extend SENSITIVE_QUERY_PARAMS with the requested credential
aliases, including client_secret, api-key, and x-amz-signature plus other common
hyphenated/vendor variants, so redact_url removes them from api_base URLs. Add
regression cases covering each newly supported alias and verify provider
transport errors do not retain their values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fcb3e137-efcb-4cd4-9319-f46c6adb5fa2

📥 Commits

Reviewing files that changed from the base of the PR and between f8b5e0f and b10c3ec.

📒 Files selected for processing (16)
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-gateway/src/lib.rs
  • crates/aisix-gateway/src/upstream_http.rs
  • crates/aisix-provider-anthropic/src/bridge.rs
  • crates/aisix-provider-azure-openai/src/bridge.rs
  • crates/aisix-provider-openai/src/bridge.rs
  • crates/aisix-provider-vertex/src/bridge.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/http_client.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-server/src/main.rs

Comment thread crates/aisix-gateway/src/upstream_http.rs Outdated
Comment thread crates/aisix-gateway/src/upstream_http.rs
CodeRabbit review on #808: the redaction denylist missed `client_secret`,
`api-key`, and `x-amz-signature`.

Enumerating exact names loses that race — SigV4 alone contributes
`X-Amz-Signature`, `X-Amz-Credential`, and `X-Amz-Security-Token`, and
every vendor spells the same concept differently. Match instead on a
suffix of the parameter name after lowercasing and stripping `-`/`_`, so
`api-key` / `api_key` / `apiKey` all resolve through `key`,
`client_secret` through `secret`, and the SigV4 trio through
`signature` / `credential` / `token`.

Over-redacting an unrelated parameter costs a little diagnostic detail;
under-redacting puts a live key in a log store, so the asymmetry favours
the broader match. `api-version`, `alt`, `keyword`, and
`signature_version` are covered by a test to pin that the diagnostic
parameters stay readable.

Also drops the assumption that port 1 is closed in the transport-error
test (same review): bind an ephemeral loopback port and release it, so
the connect is refused without depending on a fixed port being free.
The Transport arm of `reqwest_error_to_bridge` now renders its cause
chain, but the Timeout arm next to it did not — and `is_timeout()` is
satisfied by three unrelated conditions:

  - hyper's request-budget timeout (the configured `timeout`),
  - an expired `connect_timeout`, which this PR itself introduces,
  - the kernel's `ETIMEDOUT` on an unanswered SYN (`io::ErrorKind::TimedOut`).

All three collapsed into the same `upstream request timed out after
{elapsed_ms}ms`, so an operator could not tell a slow upstream from one
that was never reached — the open question in AISIX-Cloud#1093. Adding
`connect_timeout` without this would have made that worse by folding a
fourth cause into the same sentence.

`BridgeError::Timeout` gains a `cause` field, empty for the gateway's own
deadlines (message byte-identical to before) and populated from the
reqwest chain when the timeout came from the connection layer:

    upstream request timed out after 5002ms: error sending request for url
    (http://10.1.2.3:8080/v1/messages): client error (Connect):
    tcp connect error: deadline has elapsed

which is distinguishable from the kernel's `Connection timed out (os
error 110)` and from a budget expiry, where no connect-layer cause
appears at all.

The cause names the upstream host, so `envelope()` keeps it out of the
caller's response and leaves the bare sentence there — the same split
`render_bridge_upstream_envelope` already applies to upstream 5xx bodies.
Transport is deliberately left as-is: it has always surfaced the request
URL to the caller, so no new topology is exposed there either.

Verified end-to-end against a black-holed upstream: the caller receives
`upstream request timed out after 5002ms`, while the WARN log and the
per-attempt telemetry carry the full chain. The 5002ms is itself the new
`connect_timeout` doing its job — without it the same request rode the
kernel's SYN retry schedule (7s / 11s / 127s depending on
`tcp_syn_retries`).
@jarvis9443

Copy link
Copy Markdown
Contributor Author

Follow-up in b631d2e — a gap this PR was itself widening.

reqwest_error_to_bridge has two arms. This PR gave the Transport arm its cause chain and left the Timeout arm alone, but reqwest::Error::is_timeout() is satisfied by three unrelated conditions:

if err.is::<TimedOut>() { return true; }          // connect_timeout — introduced by THIS PR
if hyper_err.is_timeout() { return true; }        // the configured request budget
if io.kind() == io::ErrorKind::TimedOut { ... }   // kernel ETIMEDOUT on an unanswered SYN

All three rendered as the same upstream request timed out after {elapsed_ms}ms. So adding connect_timeout here without touching the Timeout arm would have folded a fourth cause into an already ambiguous sentence — and that ambiguity is precisely the open question in AISIX-Cloud#1093.

BridgeError::Timeout now carries a cause, empty for the gateway's own deadlines (message byte-identical to before) and populated from the reqwest chain otherwise. Measured against a black-holed upstream:

before: upstream request timed out after 5002ms
after:  upstream request timed out after 5002ms: error sending request for url
        (http://10.1.2.3:8080/v1/messages): client error (Connect):
        tcp connect error: deadline has elapsed

deadline has elapsed (our connect_timeout) is now distinguishable from the kernel's Connection timed out (os error 110), and from a budget expiry, where no connect-layer cause appears at all.

Exposure boundary. The cause names the upstream host, so envelope() keeps it out of the caller's response — the caller still gets the bare sentence. Same split render_bridge_upstream_envelope already applies to upstream 5xx bodies: operator diagnostics go to logs and per-attempt telemetry, internal topology does not reach the customer. Transport is deliberately left alone: it has always put the request URL in the caller's message, so this PR adds no new topology there either. Pinned by a test asserting the cause is in Display but not in envelope().

Worth noting for #1093 separately: the 5002ms above is the new connect_timeout. Without it the same request rode the kernel's SYN retry schedule — 7s / 11s / 127s depending on tcp_syn_retries, which is where that unexplained 7167ms came from.

Credit: gap identified by a parallel review pass on #1093.

@jarvis9443
jarvis9443 merged commit 919ea0d into main Jul 23, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/transport-diagnostics-1122 branch July 23, 2026 11:23
jarvis9443 added a commit that referenced this pull request Jul 23, 2026
Conflict in the Azure per-chunk timeout, where #808 landed
`BridgeError::Timeout`'s new `cause` field on the same lines this branch
rewrote.

Resolved by keeping both: this branch's `d.as_millis()` (the per-chunk
gap budget — the whole point of the change, and what `with_read_timeout`
already reports) plus `cause: String::new()`, since an elapsed
gateway-owned deadline has no transport-layer cause to name.
jarvis9443 added a commit that referenced this pull request Jul 24, 2026
…led connections

The knob shipped in #808 without behavioural coverage. The transport
failure it prevents only reproduces deterministically when the far end
vanishes silently (hyper discards a connection that sent a clean FIN
before reusing it), but the mechanism underneath is testable: against an
upstream that never closes first, a request after the deadline must open a
new TCP connection.

The two cases are each other's control — same gap, knob on vs off (0 =
reqwest's 90s default) — so a regression that stops applying the pool
config flips the first assertion from 2 connections to 1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant