Skip to content

Apply HTTP request queue limits to GET, HEAD and OPTIONS - #3234

Merged
kriszyp merged 11 commits into
mainfrom
fix/http-read-request-queue
Oct 30, 2026
Merged

kriszyp merged 11 commits into
mainfrom
fix/http-read-request-queue

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

Read-heavy applications have no HTTP admission control: GET, HEAD and OPTIONS never get a 503, even when the worker is overloaded. Closes #2755.

🤔 Reject reads, never queue them

Reads get a 503 under overload and are never queued. Queuing them cost 7–16% of cached-read throughput.

💡 Solution

An admitted read runs right away, in the call that received it. A read that arrives after the current event-loop iteration has already run longer than http.readRequestQueueLimit (default 5000 ms) gets a 503 without running its handler. One per-thread probe, armed only while reads arrive, measures the iteration. false turns read admission off. Writes keep the existing queue.

⚖️ Alternatives

🤔 Default on at 5000 ms

Once an iteration runs past 5 s, reads get 503s, health checks and preflights included. A 128-client loop of 20 ms renders (~2.5 s iterations) stays under it. The alternative is opt-in. 0 rejects every read while reads keep arriving (only the first read after an idle iteration is admitted).

🤔 Measure only the current iteration

A one-off stall does not shed the next iteration's reads. The first read after idle cannot see an earlier stall. Reads awaiting slow I/O are not bounded.

Framing-Verdict: chosen-approach-sound

🔧 Changes

Read admission, rejection headers and configuration

Product and architecture tour

Admission

Request Limit Behavior
GET, HEAD, OPTIONS http.readRequestQueueLimit, 5000 ms Runs now, or a 503 now; false = off
Other methods, node:http and uWS http.requestQueueLimit, 20000 ms Existing write queue, unchanged
Other methods, native Bun.serve none Immediate, unchanged

rejectWhenLoopDelayed compares the current iteration's age with the limit and either calls the handler or the 503 callback. The per-thread probe re-arms from the check phase while reads arrive and stops after one read-free iteration, so an idle worker has no timer. Look hardest at the rejection branch and the probe lifecycle.

In server/http.ts, throttleRequests uses the reject-only guard for reads and returns the bare handler when neither side is admitted. Every listener reads the one registered http key: the operations API had no registered copy, so false could not reach it. The same key feeds uWS, the per-thread UDS mirror and native Bun. makeUwsHandler takes the read limit as a new optional argument. utility/hdbTerms.ts registers the key.

Rejected reads

readRejectionHeaders resolves CORS config once per listener and adds configured response headers, Vary: Origin and, for an allowed origin, the CORS headers, without running authentication. It is used by Node, uWS and Bun. The access list is checked with corsAccessListAllows so wildcard and legacy string entries match security/auth.ts. A rejected OPTIONS preflight still fails in browsers, since preflight needs a 2xx.

Configuration

The 503 callbacks run outside the request handler's error handling, so their inputs are validated at boot. The two constraints apply to the http keys. effectiveHttpConfigValidator applies them again, through validateConfig, to the flattened config listeners read. Flattening lowercases keys and merges customFunctions.network over http after validation, so a lowercase or legacy spelling would otherwise get past the rules. The schema documents the semantics, and the allow-headers entry documents string and list values. The design note and its index entry record the probe invariant.

Cost

  • In-process microbench (Node 26.2.0, interleaved, median of 15): +~40 ns per read at 128 reads per iteration (13 → 53 ns), +~250 ns for an isolated read (two probe callbacks).
  • Serial read latency p50 (12 interleaved rounds): Node 49.9 vs 51.6 µs, uWS 52.1 vs 47.8 µs, off vs on (noise).
  • Loaded 128-client throughput was unmeasurable: the shared box was 50–74% busy and rounds varied 10×. Native Bun.serve and sustained-shed costs are unmeasured.
  • test:unit:main and test:integration:all were not run in full; CI runs them. Typecheck, lint:required and Prettier are clean.

✅ Verification

PR QA: Do reads shed on async-continuation load and recover, without ever being delayed? — queued @ d2c124a, qa-pr-harper-3234-d2c124a

🤖 Generated by OpenAI Codex and Claude Opus 5.5; posted via @kriszyp.

Release target: main — new configuration and default read-admission behavior
Related PRs: #3110 overlaps (HTTP dispatch and analytics), #3527 independent (separate config-root.schema.json entries), #3656 independent (separate config-root.schema.json entries), #2752 independent (separate DESIGN.md index entries), #372 independent (status configuration)
Complexity: medium

Deferred findings

  • 18e760af06fc873e major performance — server/http.ts — uWS mirrors have independent read admission estimates — NOT FILED
  • 5e8b64305898b2d1 major correctness — server/http.ts — Shed preflight remains a browser network error — NOT FILED
  • 6e170c48d79d6c1d major reliability — server/http.ts — Skipped disconnected reads have no request accounting — NOT FILED
  • 5d39cb90e728837a nit correctness — server/http.ts — Array header elements crash the worker; false emits Allow-Headers: false — NOT FILED
  • d08479855e014d3d nit test-coverage — integrationTests/server/request-queue.test.ts — Wire-level 503 tests do not assert Access-Control-Allow-Headers — NOT FILED
  • 6ad4484803b1c948 nit correctness — server/http.ts — 503 CORS config is read once at listener creation and no longer follows a config publish — NOT FILED
  • ed0003620287d468 nit test-coverage — unitTests/server/httpRequestQueue.test.js — No test exercises an operations-api listener with http_readRequestQueueLimit false — NOT FILED

🤔 defer this PR's major performance finding 18e760af06fc873e (uWS mirrors have independent read admission estimates) to a separate issue instead of fixing it here?

🤔 defer this PR's major correctness finding 5e8b64305898b2d1 (Shed preflight remains a browser network error) to a separate issue instead of fixing it here?

🤔 defer this PR's major reliability finding 6e170c48d79d6c1d (Skipped disconnected reads have no request accounting) to a separate issue instead of fixing it here?

Late Grok findings

6 surviving (highest nit) — Grok reviewed c977f65, ruled against a1a2f37
  • nit — validation/configValidator.ts:322 — readRequestQueueLimit: 0 passes validation and rejects every read while reads keep arriving. The schema description now documents this, but the behavior is unchanged
  • nit — server/http.ts:770 — the operations-API 503 reads allow-headers from the http_ key. Disputed: security/auth.ts:148 does the same and no operations-API key exists
  • nit — config-root.schema.json:122 — the header schema allows only string, array or null, but the validator at validation/configValidator.ts:311-321 also accepts numbers and false
  • nit — config-root.schema.json:140 — the readRequestQueueLimit default is still missing from static/defaultConfig.yaml
  • nit — server/http.ts:765 — readRejectionHeaders still re-implements the Allow-Origin, Allow-Credentials, Allow-Methods and Allow-Headers block from security/auth.ts:145-150. Only the origin check is now shared
  • nit — DESIGN.md:162 — two existing throttle.ts index entries are still joined with · on one line

Not standing: 1 resolved. Grok ran 20m (21m from launch to report); the fold adjudication (cursor-grok@c977f65aef09-406f72ef, claude) took 1m.

Review-Coverage: authored=gemini; ran=muse,claude,cursor-composer,cursor-muse,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=cursor-kimi; rounds=10; full=3 @ a1a2f37

Review-Attention: study ~15m (hot: http.ts; decisions: do-less-alternative, read-shed-default-on, zero-limit-semantics, preflight-shed, release-target-vs-evidence, deferred-18e760af, deferred-5e8b6430, deferred-6e170c48) @ a1a2f37

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

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 separate queue depths and limits for HTTP read and write requests, allowing GET, HEAD, and OPTIONS requests to be throttled independently of data-modifying requests. It updates the configuration schema, adds validation, and implements the separate queues across Node, uWS, and Bun environments, supported by comprehensive integration and unit tests. The review feedback suggests returning a safe, empty response descriptor instead of undefined when a read request is aborted in the uWS handler to prevent downstream TypeErrors. Additionally, it recommends explicitly joining array-based CORS headers with a comma and space to ensure proper formatting, and simplifying string coercion in the configuration validator by removing unnecessary typeof checks.

Comment thread server/http.ts Resolved
Comment thread server/http.ts Resolved
Comment thread validation/configValidator.ts Resolved
kriszyp and others added 11 commits October 27, 2026 23:25
Add an independent, configurable 60-second read queue across HTTP transports while preserving existing write dispatch. Register and validate the setting, consolidate listener routing, and cover queue isolation and HTTP shedding/recovery.

Co-Authored-By: OpenAI Codex (GPT-6.1) <noreply@openai.com>
Add an explicit read-admission opt-out, skip disconnected queued reads, and retain CORS and configured headers on read rejections. Validate the native Bun fetch path separately from Bun's node:http compatibility listener.

Co-Authored-By: OpenAI Codex (GPT-6.1) <noreply@openai.com>
Reject quoted read-limit scalars without silent Joi coercion, validate the configured CORS allow-headers value before the rejection path can use it, and document preflight and listener configuration boundaries.

Co-Authored-By: OpenAI Codex (GPT-6.1) <noreply@openai.com>
Validate the values as Headers.set serializes them, retaining existing string, list and scalar inputs while rejecting control characters. Exercise header lists over each HTTP runtime.

Co-Authored-By: OpenAI Codex (GPT-6.1) <noreply@openai.com>
GET, HEAD and OPTIONS no longer pass through the throttle() FIFO. An admitted
read dispatches synchronously; a read arriving after the current event-loop
iteration has run longer than http.readRequestQueueLimit (default 5000ms)
gets a 503 without running its handler. One lazily armed setImmediate probe
per thread measures the iteration and stops after a read-free iteration.
Writes keep the existing queue. The disconnected-read skips added for queued
reads are removed, since reads no longer wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…limit to every listener

Rejected-read CORS headers are resolved per listener and fall back to the
default allow-headers when the configured value is not a valid header, since
the 503 callbacks run outside the handler's error handling. Every listener,
operations API included, reads http.readRequestQueueLimit, so false disables
read admission everywhere. A listener with neither queue returns its handler
unwrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… boot

flattenConfig() merges customFunctions.network over http after validation, so
the listener-time fallback is replaced by validating that override with the
same rule as http.corsAccessControlAllowHeaders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… access list on rejected reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
flattenConfig() lowercases keys and merges customFunctions.network over http
after configValidator() runs, so a lowercase or legacy spelling escaped the
http rules. validateConfig() now checks http_readrequestqueuelimit and
http_corsaccesscontrolallowheaders on the flattened config, replacing the
customFunctions.network schema entries.

Dispatch-Task: pr-maint-0cc9e6def5ce915ffc5fc4ff87e04940
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Gemini <noreply@google.com>
…view findings

Dispatch-Task: pr-maint-0cc9e6def5ce915ffc5fc4ff87e04940
Co-Authored-By: Gemini <noreply@google.com>
… limit

Dispatch-Task: pr-maint-0cc9e6def5ce915ffc5fc4ff87e04940
Co-Authored-By: Gemini <noreply@google.com>
@kriszyp
kriszyp force-pushed the fix/http-read-request-queue branch from d2c124a to a1a2f37 Compare October 28, 2026 05:52
@kriszyp
kriszyp merged commit 91faf82 into main Oct 30, 2026
74 checks passed
@kriszyp
kriszyp deleted the fix/http-read-request-queue branch October 30, 2026 13:24
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.

http.requestQueueLimit is not applied to GET requests, so read-heavy deployments have no admission control

1 participant