Skip to content

feat(downstream): configurable idle timeout and SSE heartbeat on the inbound side - #818

Merged
jarvis9443 merged 4 commits into
mainfrom
feat/downstream-idle-timeout-1126
Jul 24, 2026
Merged

feat(downstream): configurable idle timeout and SSE heartbeat on the inbound side#818
jarvis9443 merged 4 commits into
mainfrom
feat/downstream-idle-timeout-1126

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Problem

upstream: (#808) bounded the connections the gateway dials out with. The inbound half had nothing:

  1. No downstream idle timeout at all. hyper 1.x defaults header_read_timeout to 30s, but Time::check silently drops a defaulted duration when no timer is installed — and neither axum::serve nor axum_server installs one. So an accepted connection was held open forever once it went idle.
  2. The SSE heartbeat only existed on /v1/chat/completions, at a hardcoded 15s. /v1/messages and /v1/responses streams sent nothing while the model was silent.
  3. Half the outbound clients never got fix(upstream): name the real transport fault and bound the connection layer #808's settings. Only the four provider bridges and the proxy's shared client used client_builder(); the 7 guardrail clients, MCP OAuth, A2A, telemetry, heartbeat and the OTLP exporter were still on reqwest's defaults (no connect timeout, TCP keepalive off, 90s pooled-connection lifetime).

Implementation

New downstream: block, the mirror of upstream::

downstream:
  idle_timeout_secs: 0          # 0 = never close an idle connection
  # sse_keepalive_interval_secs: 15

idle_timeout_secs goes on hyper's header-read timer. That timer arms only while hyper is waiting for a request head, and hyper only waits for one once the previous response has been fully written (Conn::can_read_head needs the read half back at Init, which try_keep_alive reaches only when reading and writing are done). So it covers exactly the between-requests window — a slow model or a long SSE stream is never interrupted. Reaching the builder means the plain-HTTP listener moves from axum::serve to axum_server, which the TLS listener already used; serve_connection_with_upgrades and ConnectInfo<SocketAddr> are preserved, so WebSocket upgrades and real-ip resolution are unaffected. HTTP/1.1 only — hyper has no h2 equivalent.

Default 0 = today's behaviour. Closing first is what hands the node in front a connection it still considers usable — the same failure this gateway avoids upstream by keeping pool_idle_timeout_secs low — and a fronting Envoy pools for an hour by default. Set it above the pool idle timeout of whatever sits in front.

sse_keepalive_interval_secs (default 15, 0 disables) now drives every SSE surface: axum's Sse keep-alive on chat, and a with_heartbeat stream wrapper on bridged /v1/messages, bridged /v1/responses, and the /v1/responses byte passthrough. The frame is :\n\n — the same comment axum writes, ignored by every conforming SSE parser. Not applied to opaque binary passthroughs (audio, images), where it would corrupt the body.

Uniform connection layer: guardrails, MCP OAuth, A2A, telemetry, heartbeat and the OTLP exporter now build from aisix_gateway::client_builder(). A test walks every crate's production source and fails on a bare reqwest::Client::builder() / ::new(), so the next client can't drift back (verified it catches a reintroduced offender).

Which surfaces the heartbeat covers

Audited every response-body outlet in aisix-proxy, since the issue asks whether the raw passthrough paths need one too:

Surface Shape Heartbeat
/v1/chat/completions (both stream sites) axum Sse yes, now configurable
/v1/messages bridged + native passthrough SSE byte stream yes, new
/v1/responses bridged + native passthrough SSE byte stream yes, new
/passthrough/:provider/*rest not streaming — buffers the whole upstream body (bytes().await) for the output-guardrail scan n/a
/v1/completions non-streaming, always Json(...) n/a
audio / images / videos binary or non-streaming deliberately not — a comment frame would corrupt the body
/v1/realtime WebSocket, has its own idle semantics (stream_timeout) and would need WS ping frames, not SSE comments not covered

Worth flagging from that audit: the raw tunnel buffers, so a slow provider call through /passthrough/... puts zero bytes on the client connection until the whole body has arrived — the same "looks idle" exposure the heartbeat fixes for SSE, and one that can't be fixed the same way because the content type is unknown. Out of scope here; noting it so it isn't assumed covered.

Not implemented, deliberately

Behaviour changes

  • Graceful shutdown now drains without a deadline on both listeners (the TLS one previously capped at 10s). An in-flight stream can run for minutes and the platform already caps total shutdown time; idle connections are closed immediately either way.
  • SSE responses on /v1/messages and /v1/responses now carry heartbeat comments during upstream silence.
  • Guardrail/MCP/A2A/telemetry/exporter calls get a 5s connect timeout, TCP keepalive, and a 30s pool idle timeout.

Tests

  • downstream-connection-e2e.test.ts: an idle keep-alive connection is closed at the deadline; a request in flight past the deadline still completes; heartbeats appear on all three SSE code paths while the upstream is silent, without disturbing the payload. Mutation-checked — with the idle timeout un-applied and the interval hardcoded, 4 of the 5 fail (the in-flight case is the control and keeps passing).
  • Unit tests for the heartbeat combinator (silence heartbeats, busy stream untouched, disabled = pass-through, terminal error preserved) and for the downstream: config defaults/overrides.
  • upstream-pool-idle-e2e.test.ts: pool_idle_timeout_secs shipped in fix(upstream): name the real transport fault and bound the connection layer #808 without behavioural coverage. Against an upstream that never closes first, a request after the deadline opens a new TCP connection; the same gap with the knob off reuses the pooled one. The two cases are each other's control.
  • Re-ran the listener-sensitive suites: TLS, real-ip/XFF, WebSocket realtime, SIGHUP reload, SIGTERM shutdown, admin-off, #554 timeout fallback, audio timeout, streaming usage, guardrails.

Fixes api7/AISIX-Cloud#1126

Summary by CodeRabbit

  • New Features

    • Added configurable downstream idle timeouts for HTTP connections.
    • Added configurable SSE heartbeats, enabled by default at 15 seconds, to keep quiet streams active.
    • Applied SSE keep-alives across supported chat, messages, and responses streaming endpoints.
    • Standardized outbound connection handling for improved reuse and timeout consistency.
  • Tests

    • Added end-to-end coverage for downstream timeouts, SSE heartbeats, and upstream connection pooling.

…inbound side

`upstream:` (#808) bounded the connections the gateway dials out with. The
inbound half had nothing: an accepted connection was held open forever once
idle, and only `/v1/chat/completions` sent an SSE heartbeat, on a hardcoded
15s interval.

New `downstream:` block, the mirror of `upstream:`:

- `idle_timeout_secs` closes a connection that sits idle between requests.
  Applied through hyper's header-read timer, which arms only while waiting
  for a request head — and hyper only waits for one after the previous
  response is fully written — so a slow model or a long SSE stream is never
  interrupted. hyper's own 30s default was silently dropped because neither
  axum nor axum_server installs a timer, so the plain-HTTP listener moves
  from `axum::serve` to `axum_server` (which exposes the connection builder;
  the TLS listener already used it). HTTP/1.1 only — hyper has no h2
  equivalent.

  Defaults to 0 (never close), i.e. today's behaviour. A node in front pools
  its own connections — Envoy's upstream idle default is an hour — and
  closing first is what hands *it* a connection it still considers usable,
  the same failure this gateway avoids upstream by keeping
  `pool_idle_timeout_secs` low. Set it above the pool idle timeout of
  whatever sits in front.

- `sse_keepalive_interval_secs` (default 15, 0 disables) now drives the
  heartbeat on every SSE surface, not just chat: bridged `/v1/messages`,
  bridged and passthrough `/v1/responses`. A model slow to its first token
  no longer looks like an abandoned connection to a proxy in front.

Also finishes what #808 started: the guardrail (7), MCP OAuth, A2A,
telemetry, heartbeat, and OTLP-exporter clients were still on reqwest's
defaults — no connect timeout, TCP keepalive off, 90s pooled-connection
lifetime. They all build from `aisix_gateway::client_builder()` now, and a
test walks the workspace to keep the next one from drifting.

Not implemented, deliberately: per-provider overrides of the pool settings
(the pool idle timeout only needs to be below the *shortest* hop on the
path, so one global value suffices; per-provider pools multiply FDs and
memory for no reachability gain), `max_requests_per_connection` /
`max_connection_lifetime` (reqwest exposes neither), and a separate
upstream stream-idle timeout (per-model `stream_timeout` already bounds
each inter-chunk gap).

Refs AISIX-Cloud#1126
@coderabbitai

coderabbitai Bot commented Jul 24, 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: 4 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: 11c48feb-3674-474f-9edb-7f6ebfd93ed1

📥 Commits

Reviewing files that changed from the base of the PR and between 47942dc and c291baa.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-a2a/Cargo.toml
  • crates/aisix-a2a/src/bridge.rs
  • crates/aisix-core/src/config.rs
  • crates/aisix-gateway/src/upstream_http.rs
  • crates/aisix-guardrails/src/aliyun.rs
  • crates/aisix-guardrails/src/aliyun_ai_guardrail.rs
  • crates/aisix-guardrails/src/lakera.rs
  • crates/aisix-guardrails/src/openai_moderation.rs
  • crates/aisix-guardrails/src/presidio.rs
  • crates/aisix-guardrails/src/prompt_shield.rs
  • crates/aisix-guardrails/src/text_moderation.rs
  • crates/aisix-mcp/Cargo.toml
  • crates/aisix-mcp/src/oauth.rs
  • crates/aisix-obs/Cargo.toml
  • crates/aisix-obs/src/otlp_http_sink.rs
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/Cargo.toml
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/responses_bridge.rs
  • crates/aisix-proxy/src/sse_keepalive.rs
  • crates/aisix-server/Cargo.toml
  • crates/aisix-server/src/heartbeat.rs
  • crates/aisix-server/src/main.rs
  • crates/aisix-server/src/telemetry.rs
  • tests/e2e/src/cases/downstream-connection-e2e.test.ts
  • tests/e2e/src/cases/upstream-pool-idle-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/downstream-idle-timeout-1126

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

…in()

`run` is the entry the integration tests drive directly, so reading
`downstream.sse_keepalive_interval_secs` in `main` left those paths on the
default while `idle_timeout_secs` — read inside `run` — was honoured. Both
now come from the same place.
…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.
@jarvis9443

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Action performed

Full review triggered.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 47 minutes.

@jarvis9443

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 8 minutes.

@jarvis9443
jarvis9443 merged commit 3295d56 into main Jul 24, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the feat/downstream-idle-timeout-1126 branch July 24, 2026 12:36
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