fix(server): bound inbound request concurrency - #271
Conversation
Shed requests above a configurable process-wide limit while holding each permit through response-body completion. Keep liveness routes exempt and preserve the OpenAI error envelope on inbound Codex paths. Closes #260
There was a problem hiding this comment.
Code Review
This pull request introduces an inbound concurrency limiter to bound in-flight requests using a new max_concurrent_requests configuration setting, shedding excess requests with a 503 status while exempting liveness routes. The code review feedback recommends two performance and robustness improvements in src/concurrency.rs: avoiding an unnecessary Arc clone on the request hot path when acquiring semaphore permits, and ensuring the concurrency permit is released immediately if a streaming response encounters an error.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Review follow-ups on the inbound concurrency limit: - Reject `max_concurrent_requests` above `Semaphore::MAX_PERMITS` in `Config::validate`. Tokio's constructor asserts on it, so an out-of-range value previously passed `shunt check` and then panicked at boot inside `build_router`. The bound is platform-dependent (~5.4e8 on 32-bit), so the guard also keeps the accepted range from varying by build target. - Count shed requests in a new `shunt.requests_shed` metric. A shed request never reaches a handler, so it is absent from `record_proxied_request` and from request spans; with the rejection logged only at `debug!`, a saturated gateway was invisible at the default `shunt=info` filter. - Correct the scope of the documented guarantee. The gate bounds request *count*, not bytes: each admitted request may still buffer up to `MAX_REQUEST_BODY_BYTES`, and a permit is held for as long as the request is in flight, so it is not on its own a resident-memory bound. Say so in the field docs and the shared-gateway guide (all four locales). - Document the limit in the shared-gateway guide and add a `503` row to the troubleshooting reference, both across en/ko/ja/zh-cn. - Drop the stale count in the config-reload doc and list every restart-only field, including the route-tree toggles and `[otel]`. Refs #260.
Two comment-only clarifications on the inbound concurrency limiter: - `CODEX_PATHS` duplicates the routes registered in `build_router` because this middleware runs above routing and cannot ask the router what would have matched. Record that coupling: a Codex route added there but not here would hand an OpenAI-protocol client an Anthropic-shaped 503, and no test would fail unless it is also added to the table-driven case. - `limit_requests` described the permit as released when the body "is dropped by a disconnected client". Disconnect is the common cause, but any owner dropping the body releases it, and a terminal error frame now releases it too. Refs #260.
The concurrency middleware runs above routing, so it classifies Codex paths by string to pick the OpenAI Responses error envelope over the Anthropic one (AGENTS.md). That meant two independent copies of the path set: five literals in `build_router` and a `CODEX_PATHS` array in `concurrency`. A Codex route added to the router but not the array would have silently handed an OpenAI-protocol client an Anthropic-shaped 503, and the envelope test iterated the same array it was meant to verify, so nothing would fail. Move the path sets to `codex_endpoint::PATHS` and `codex_analytics::PATHS`, register the routes from them, classify from them, and iterate them in the test. Registration and error shape can no longer drift apart. Also fix the zero-limit test, which held only two concurrent responses and so would have passed if `0` were silently replaced by the finite 1024 default; it now exceeds that default. Restores `cargo fmt` compliance and the missing `StreamExt` import in the mid-stream test.
…inbound-reque main advanced past this branch (the codex-ws borrow-envelope perf work and its benchmark/changelog updates), leaving PR #271 in a CONFLICTING state. GitHub builds `pull_request` workflows against the PR merge ref, which it cannot create while the PR conflicts, so CI and CodSpeed stopped running entirely on new pushes — only CodeQL, which uses `refs/pull/271/head`, still fired. Merging rather than rebasing keeps the already-published commits on this branch intact. The single conflict was `.please/docs/review/rejected-findings.jsonl`, an append-only ledger both sides appended to. Resolved as the union of both sides, ordered by `rejected_at`.
`PermitBody::poll_frame` releases only on the terminal arms, but every existing test observed just those arms: the mid-stream test polled the one ready data frame and stopped, and the drop test never polled at all. A regression that also released on `Poll::Pending` therefore passed the whole suite while letting any temporarily stalled SSE stream stop counting against the cap — the exact quantity this limit bounds. Add a test that polls an in-progress stream to `Poll::Pending` and asserts the slot is still occupied. Proven load-bearing by mutation: with `| Poll::Pending` added to the release condition, this test is the only one of the 13 that fails. It observes the pending state without suspending by wrapping the inner poll in `Poll::Ready`; awaiting `poll_frame` directly would hang forever rather than fail, since `stream::pending` never wakes the task. The stalling router and first-chunk assertion are factored out so both streaming tests share them. Also add `shunt.requests_shed` to the OpenTelemetry metric-series reference in all four locales. The shared-gateway guide added by this PR already tells operators to watch that counter, but it was missing from the inventory page that claims to list every exported series.
Greptile SummaryThis PR adds boot-time inbound request concurrency admission control.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| src/concurrency.rs | Implements immediate admission shedding and retains permits through response-body termination, with focused lifecycle tests. |
| src/server.rs | Installs the boot-time gate around application routes while merging root and health liveness routes outside it. |
| src/config.rs | Adds the concurrency setting, its default, and validation against Tokio semaphore limits. |
| src/reload.rs | Detects changes to the boot-fixed limit and warns that a restart is required. |
| src/codex_endpoint.rs | Centralizes inbound Codex response paths for registration and overload-envelope classification. |
| src/codex_analytics.rs | Centralizes Codex analytics paths alongside the response endpoint path set. |
| src/metrics.rs | Adds a counter for requests shed by the concurrency gate. |
Sequence Diagram
sequenceDiagram
participant Client
participant Router
participant Gate as Concurrency gate
participant Handler
participant Body as Response body
Client->>Router: Request
alt Root or health endpoint
Router->>Handler: Bypass gate
Handler-->>Client: Liveness response
else Limited route with capacity
Router->>Gate: Try to acquire permit
Gate->>Handler: Admit request
Handler-->>Body: Produce response
Body-->>Client: Stream frames
Body->>Gate: Release permit on EOS, error, or drop
else Limited route is saturated
Gate-->>Client: 503 + Retry-After: 1
end
Reviews (4): Last reviewed commit: "fix(server): scope the end-of-stream rel..." | Re-trigger Greptile
`try_acquire_owned` consumes an `Arc<Semaphore>`, and axum's `State` extractor already hands the middleware an owned clone of `ConcurrencyLimit`. Cloning the inner `Arc` again therefore added a redundant refcount increment/decrement to every inbound request on the hot path; moving it out of the owned state is enough. Raised by gemini-code-assist on #271.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces an inbound concurrency limiter (max_concurrent_requests, default 1024) to bound the number of concurrent in-flight requests and prevent memory exhaustion. Excess requests are shed immediately with a 503 Service Unavailable and a Retry-After: 1 header, while / and /health routes bypass this limit to remain available for liveness probes. The feedback suggests releasing the concurrency permit immediately when the last frame is polled or when is_end_stream() becomes true, rather than waiting for a subsequent poll or drop, ensuring resources are freed as early as possible.
`poll_frame` released only on the terminal arms, so a consumer that trusts `is_end_stream()` and stops polling after the last data frame never produced `Poll::Ready(None)` — the slot then fell through to `Drop`, which the comment on this function already notes can lag well past end-of-stream because hyper may hold the body while finishing the connection. A full (non-streaming) response body takes exactly that path. Check `is_end_stream()` alongside the terminal arms. Per the `http-body` contract end-of-stream means no further frames including trailers, so this cannot truncate a trailer; the existing trailer test still passes. Covered by a new test that polls a full body's single data frame, asserts the body reports end-of-stream, and then requires the next request to be admitted — keeping the body alive across the assertion so a pass cannot come from `Drop`. It fails on the previous code with 503 != 200. Re-ran the `Poll::Pending` mutation to confirm this clause does not weaken that guard: it still fails only the pending-retention test. Raised by gemini-code-assist on #271.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces an inbound concurrency limiter (max_concurrent_requests, defaulting to 1024) to bound in-flight requests and shed excess traffic immediately with a 503 Service Unavailable response and a Retry-After: 1 header. It adds the concurrency module, integrates it as a middleware layer in the router (exempting / and /health), introduces a shunt.requests_shed metric, and updates the documentation across multiple locales. Feedback on the implementation highlights a correctness issue in PermitBody::poll_frame where the concurrency permit could be prematurely released if is_end_stream() returns true while poll_frame yields Poll::Pending.
The `is_end_stream()` check added in ef18bcf was OR'd against the terminal arms, so it was consulted even when `poll_frame` returned `Poll::Pending`. A buffering wrapper can report end-of-stream for its drained *inner* body while still owing frames of its own; releasing on that coincidence would hand the slot away mid-stream — the over-admission this limit exists to prevent. Match on the frame instead, consulting `is_end_stream()` only for `Poll::Ready(Some(Ok(_)))`. `Poll::Pending` now never releases, whatever the inner body claims. Covered by a new test whose body reports end-of-stream only after being polled, and never yields a frame — constructed not-yet-ended so it survives the `PermitBody::new` short-circuit and actually reaches `poll_frame`. It is the only failure when the release condition is regressed to the previous `||` form. Raised by gemini-code-assist on #271.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces an inbound concurrency limiter to bound the number of in-flight requests (defaulting to 1024) and prevent memory exhaustion from buffered request bodies. It implements a ConcurrencyLimit middleware using a Tokio semaphore, wrapping response bodies in a custom PermitBody to hold permits until stream completion or client disconnection. Liveness probes (/ and /health) bypass this limit to ensure the gateway remains responsive under load. The PR also adds configuration options, extensive unit/integration tests, Prometheus/OpenTelemetry metrics (shunt.requests_shed), and comprehensive multi-language documentation. No review comments were provided, so there is no additional feedback to address.
|



Summary
axum::servehad no concurrency limit, so the number of in-flight inbound client requests was unbounded. Inbound auth is opt-in, so on an unauthenticated deployment this was reachable by anyone who could connect — and per-path memory bounds (the gzip semaphore from #256) can't be reasoned about without a ceiling on concurrent stream count.[server] max_concurrent_requests(default1024,0disables). At0no layer is installed at all, so the previous behavior is preserved exactly.503withRetry-After: 1, in the Anthropic error shape (overloaded_error) — or the OpenAI Responses envelope on the five inbound Codex-endpoint paths, per the AGENTS.md error-shape rule. Queueing was rejected deliberately: queued requests keep their buffered bodies resident, which defeats the memory bound that motivates the issue.PermitBodywraps the response body and delegatespoll_frame/is_end_stream/size_hint, releasing the permit at end-of-stream or on drop (client disconnect). Releasing at the response head would leave concurrent SSE streams effectively unbounded, which is the exact quantity this issue is about. Delegating every frame — rather thaninto_data_stream()— keeps HTTP trailers intact./and/healthare merged outside the layer. Shedding liveness probes under load would make a load balancer evict an instance that is still serving work.warn!and requires a restart, matching howserver.bindand[sentry]already behave.Closes #260.
Milestone / spec
No milestone or frozen behavior spec applies — this is a focused cross-cutting server bound, not a new subsystem.
docs/config-reload.md§4 gained the third restart-only field;docs/running.mddocuments the key and the liveness exemption.Design decisions
Two knobs the issue raised were settled explicitly rather than left implicit:
503)1024,0= unlimitedsse_keepalive_secondspattern (numeric,0disables) in the same struct.HTTP/2
max_concurrent_streamsis a separate knob and is not in this PR:axum::servedoes not expose hyper's h2 builder, so it requires replacing the accept loop while preservingConnectInfopropagation and shutdown semantics. Tracked as #268.Checklist
cargo buildpasses (cargo clippy --all-targets --all-featuresbuilds every target)cargo test --all-features --workspacepasses: 1287 tests across 17 targets, 0 failed, 1 ignoredcargo clippy --all-targets --all-features -- -D warningscleancargo fmt --all --checkcleansrc/concurrency.rsis 575 lines, but only 141 are production code; the other 434 are its test module, in line with the rest of the tree (config.rsis 5277)docs/updated if this change deviates from itREADME.md,docs/running.md,docs/config-reload.md, both config examples, and the config reference in English +ko/ja/zh-cn(wiki/untouched, it is generated)Notes for reviewers
The streaming permit is the part worth scrutinising.
PermitBody::poll_framereleases the permit onPoll::Ready(None)orPoll::Ready(Some(Err(_)));Dropcovers the disconnect path. The error arm was a real leak found late: a body terminating with an error held its permit until the wrapper happened to be dropped. Eight review passes read this function without catching it — only writing a test that observes the error path did. Both routes have their own test, and the EOS test is deliberately written to be load-bearing:An earlier version used
to_bytes(body, ..), which takes the body by value — so it released the permit throughDropand would still have passed with the EOS branch deleted. This was verified by mutation: withself.permit.take()removed, theto_bytesversion passes and the version above fails503 != 200.Other things to look at closely:
overloaded_erroris Anthropic's type for 529, and this returns 503. Chosen because it is the most semantically accurate of the documented types and clients that special-case it back off correctly;api_erroris the alternative if you would rather the type track the status convention.is_codex_pathmatches the five codex paths exactly, readingcodex_endpoint::PATHS/codex_analytics::PATHS— the same constantsbuild_routerregisters from, so a new Codex route cannot get the wrong error shape. These replaced a hand-maintained copy that the envelope test also iterated, which made that test tautological. When[server.codex_endpoint]is disabled those paths would otherwise 404, so an over-limit request to them returns an OpenAI-shaped 503 for a route that is not registered. Harmless, but intentional rather than overlooked.http-bodywas promoted to a direct dependency solely soPermitBodycan nameFrame/SizeHint. Nopin-projectis needed —axum::body::BodyisUnpin, soPin::new(&mut self.inner)suffices..merge(liveness_router)runs after.layer(...), the merged router's fallback ends up outside the gate — verified by probe: on a saturated router/routesreturns 503 while/does-not-existreturns 404. An earlier revision of this description claimed the opposite; this is the corrected reading.Summary by cubic
Bound inbound request concurrency process‑wide to cap in‑flight requests. Over‑cap requests return 503 with Retry‑After; streaming keeps a slot until the body ends; Codex routes keep the OpenAI error shape.
New Features
[server] max_concurrent_requests(default1024;0disables). Fixed at startup; reload logs a warning and requires restart./and/healthbypass the gate.503withRetry-After: 1; Codex and analytics paths use the OpenAI error envelope.PATHSconstants (codex_endpoint,codex_analytics) so route registration and error shape stay in sync.Poll::Pending. Trailers are preserved.tokio::sync::Semaphore::MAX_PERMITS; reject out‑of‑range values inConfig::validate.shunt.requests_shedcounts rejected requests; the rejection is logged atdebug!.Arcintotry_acquire_ownedinstead of cloning per request.Dependencies
http-bodyto delegate frames while holding permits.http-body-utilfor test helpers.Written for commit 6f4b954. Summary will update on new commits.
Review follow-ups (commit
025c000)A review pass over this diff surfaced findings that landed as a second commit:
max_concurrent_requestswas unvalidated.tokio::sync::Semaphore::newasserts aboveSemaphore::MAX_PERMITS, so an out-of-range value passedshunt checkand then panicked at boot.Config::validatenow rejects it. The bound is platform-dependent (usize::MAX >> 3≈ 5.4e8 on 32-bit), so the guard also stops the accepted range varying by build target. The test pins the constant against tokio's ownMAX_PERMITSso a dependency bump cannot silently drift it.record_proxied_requestand from request spans, and the rejection is logged only atdebug!while the default filter isshunt=info. Added ashunt.requests_shedcounter.reference/troubleshootinggained a503 overloaded_errorrow, anddocs/config-reload.mdlost a stale "three fields" count that under-reported the actual restart-only set. All site changes are in en/ko/ja/zh-cn.Known limitations, not addressed here
Confirmed during review, deliberately out of scope for this PR — each needs its own design rather than being bolted onto the admission gate:
proxy/failover.rsbuffers the body beforecheck_inbound_auth, so even with[server.auth]configured an unauthenticated client can hold permits by trickling bodies. There is no inbound request timeout, and a naive global one would break legitimate long SSE streams.Content-Lengthrejection) is the real fix; lowering the request count is the wrong lever.axum::servehas no connection cap or header-read timeout, so connections can consume resources before ever reaching this middleware. Same class as the HTTP/2 knob in feat(server): expose HTTP/2 max_concurrent_streams #268.