Skip to content

fix(server): bound inbound request concurrency - #271

Merged
amondnet merged 10 commits into
mainfrom
amondnet/fix-server-no-concurrency-limit-on-inbound-reque
Jul 29, 2026
Merged

fix(server): bound inbound request concurrency#271
amondnet merged 10 commits into
mainfrom
amondnet/fix-server-no-concurrency-limit-on-inbound-reque

Conversation

@amondnet

@amondnet amondnet commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

axum::serve had 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.

Scope of the guarantee. This bounds in-flight request count, not bytes. Each admitted request may still buffer a body up to MAX_REQUEST_BODY_BYTES (64 MiB), and a permit is held for as long as the request is in flight — with no inbound request timeout, a client trickling a body holds its slot. So max_concurrent_requests makes the ceiling finite where it previously was not, but is not on its own a resident-memory bound. Surfaced by review; documented in the shared-gateway guide rather than overclaimed. Follow-ups below.

  • Add [server] max_concurrent_requests (default 1024, 0 disables). At 0 no layer is installed at all, so the previous behavior is preserved exactly.
  • Shed, never queue. An over-limit request gets an immediate 503 with Retry-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.
  • The permit is held until the response body ends, not until the handler returns. PermitBody wraps the response body and delegates poll_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 than into_data_stream() — keeps HTTP trailers intact.
  • / and /health are merged outside the layer. Shedding liveness probes under load would make a load balancer evict an instance that is still serving work.
  • The limit is fixed at boot (the semaphore is a router layer). A reload accepts a changed value but logs a warn! and requires a restart, matching how server.bind and [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.md documents the key and the liveness exemption.

Design decisions

Two knobs the issue raised were settled explicitly rather than left implicit:

Question Choice Why
Shed or queue? Shed (503) Queued requests hold buffered bodies resident; a queue without a depth cap just relocates the unbounded growth.
Permit scope on a stream? Until body EOS/drop The issue's rationale is concurrent-stream memory; releasing at the response head would not bound it.
Default value? 1024, 0 = unlimited Pre-1.0 is the window to set a safer default. An off-by-default knob only protects operators who already know to set it, and the issue's concern is unauthenticated deployments. 1024 is far above any single-user deployment, so it is not a practical behavior change. Mirrors the existing sse_keepalive_seconds pattern (numeric, 0 disables) in the same struct.

HTTP/2 max_concurrent_streams is a separate knob and is not in this PR: axum::serve does not expose hyper's h2 builder, so it requires replacing the accept loop while preserving ConnectInfo propagation and shutdown semantics. Tracked as #268.

Checklist

  • cargo build passes (cargo clippy --all-targets --all-features builds every target)
  • cargo test --all-features --workspace passes: 1287 tests across 17 targets, 0 failed, 1 ignored
  • cargo clippy --all-targets --all-features -- -D warnings clean
  • cargo fmt --all --check clean
  • Source files stay focused — src/concurrency.rs is 575 lines, but only 141 are production code; the other 434 are its test module, in line with the rest of the tree (config.rs is 5277)
  • English only; matches surrounding style
  • Frozen spec in docs/ updated if this change deviates from it
  • User-facing docs updated — README.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)
  • Any new GitHub Action is pinned to a full commit SHA (none added)

Notes for reviewers

The streaming permit is the part worth scrutinising. PermitBody::poll_frame releases the permit on Poll::Ready(None) or Poll::Ready(Some(Err(_))); Drop covers 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:

let (_parts, mut body) = first.into_parts();
while poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)).await.is_some() {}

An earlier version used to_bytes(body, ..), which takes the body by value — so it released the permit through Drop and would still have passed with the EOS branch deleted. This was verified by mutation: with self.permit.take() removed, the to_bytes version passes and the version above fails 503 != 200.

Other things to look at closely:

  • overloaded_error is 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_error is the alternative if you would rather the type track the status convention.
  • is_codex_path matches the five codex paths exactly, reading codex_endpoint::PATHS / codex_analytics::PATHS — the same constants build_router registers 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-body was promoted to a direct dependency solely so PermitBody can name Frame/SizeHint. No pin-project is needed — axum::body::Body is Unpin, so Pin::new(&mut self.inner) suffices.
  • The default 404 fallback is not behind the limiter. Because .merge(liveness_router) runs after .layer(...), the merged router's fallback ends up outside the gate — verified by probe: on a saturated router /routes returns 503 while /does-not-exist returns 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

    • New [server] max_concurrent_requests (default 1024; 0 disables). Fixed at startup; reload logs a warning and requires restart. / and /health bypass the gate.
    • Shed, never queue: immediate 503 with Retry-After: 1; Codex and analytics paths use the OpenAI error envelope.
    • Router and limiter read Codex path lists from shared PATHS constants (codex_endpoint, codex_analytics) so route registration and error shape stay in sync.
    • Permit held through response‑body completion (incl. SSE); released on end‑of‑stream, error, or drop. Also releases when the body reports end‑of‑stream even if no final poll occurs, but only after a ready frame; never on Poll::Pending. Trailers are preserved.
    • Validate the limit at load against tokio::sync::Semaphore::MAX_PERMITS; reject out‑of‑range values in Config::validate.
    • New metric shunt.requests_shed counts rejected requests; the rejection is logged at debug!.
    • Perf: remove a hot‑path refcount by moving the limiter Arc into try_acquire_owned instead of cloning per request.
  • Dependencies

    • Added http-body to delegate frames while holding permits.
    • Added dev‑dependency http-body-util for 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_requests was unvalidated. tokio::sync::Semaphore::new asserts above Semaphore::MAX_PERMITS, so an out-of-range value passed shunt check and then panicked at boot. Config::validate now 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 own MAX_PERMITS so a dependency bump cannot silently drift it.
  • A saturated gateway was invisible. Shed requests never reach a handler, so they are absent from record_proxied_request and from request spans, and the rejection is logged only at debug! while the default filter is shunt=info. Added a shunt.requests_shed counter.
  • Docs corrected and extended: the shared-gateway guide now documents the limit and its scope, reference/troubleshooting gained a 503 overloaded_error row, and docs/config-reload.md lost 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:

  1. Slow request bodies pin permits. proxy/failover.rs buffers the body before check_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.
  2. The byte budget scales with the cap. 1024 × 64 MiB is a large aggregate. A byte-weighted admission budget (or early Content-Length rejection) is the real fix; lowering the request count is the wrong lever.
  3. Pre-admission slow-loris. axum::serve has 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.

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

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/concurrency.rs Outdated
Comment thread src/concurrency.rs Outdated
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 72 untouched benchmarks


Comparing amondnet/fix-server-no-concurrency-limit-on-inbound-reque (6f4b954) with main (b84a3bd)

Open in CodSpeed

amondnet added 6 commits July 29, 2026 20:49
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.
@amondnet
amondnet marked this pull request as ready for review July 29, 2026 13:59
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds boot-time inbound request concurrency admission control.

  • Adds server.max_concurrent_requests, validation, restart-only reload warnings, and immediate overload shedding with a dedicated metric.
  • Holds admission permits through response-body completion or drop while exempting liveness endpoints.
  • Preserves protocol-specific error envelopes for Codex routes and documents the behavior across configuration examples and localized guides.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

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
Loading

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.
@amondnet

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/concurrency.rs Outdated
`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.
@amondnet

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/concurrency.rs Outdated
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.
@amondnet

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist 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.

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.

@sonarqubecloud

Copy link
Copy Markdown

@amondnet
amondnet merged commit 916e238 into main Jul 29, 2026
16 checks passed
@amondnet
amondnet deleted the amondnet/fix-server-no-concurrency-limit-on-inbound-reque branch July 29, 2026 14:45
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.

fix(server): no concurrency limit on inbound requests

1 participant