Skip to content

[Bug] Bun.serve has no maxRequestBodySize: >128 MiB /v1/responses bodies get an empty-body 413 that Codex renders as "Unknown error" #1601

Description

@nekonade

Summary

Bun.serve() in src/server/index.ts never sets maxRequestBodySize, so Bun's default 128 MiB cap applies to the /v1/responses HTTP listener. When Codex posts a body larger than that, Bun rejects it at the HTTP layer before fetch() runs, returning 413 with an empty body. Codex renders an empty error body as the opaque string Unknown error — exactly the failure mode #452 and src/server/responses/passthrough-error.ts were written to prevent, except this one happens one layer below anything opencodex controls.

Client-visible symptom:

unexpected status 413 Payload Too Large: Unknown error, url: http://127.0.0.1:10100/v1/responses

Because the handler never runs, the request leaves no trace at all: no usage.jsonl row, no service.log line. From the operator's side the proxy looks perfectly healthy (ocx healthProxy healthy) while every turn of the affected session fails.

Environment

  • opencodex 2.10.0 (reproduced); 2.14.1 is still affected — see "Still present on latest" below
  • Bun 1.3.14 (bundled)
  • Codex CLI 0.147.0-alpha.6.5
  • macOS (darwin arm64)
  • websockets: false in config.json, so Codex falls back to HTTP POST and always takes this path

Root cause

src/server/index.ts (2.10.0, L391):

const server: Server<WsData> = Bun.serve<WsData>({
  port: listenPort,
  hostname: bindHost,
  idleTimeout: 255,
  async fetch(req, requestServer): Promise<Response> {

No maxRequestBodySize here, and rg maxRequestBodySize src/ returns nothing anywhere in the package. Bun therefore applies its default of 128 MiB and answers oversized requests itself.

Every 413 opencodex itself produces carries a JSON envelope with a populated error.message:

  • formatErrorResponseclassifyError (src/lib/errors.ts) always preserves message
  • decodeRequestErrorResponse413 invalid_request_error for DecompressedBodyTooLargeError
  • 413 request_too_large for TranslatorBudgetExceededError
  • formatPassthroughUpstreamError explicitly re-wraps empty upstream bodies into Provider error 413: (empty body)

So an empty-body 413 cannot originate from opencodex's own code paths. That is what makes the Bun layer identifiable as the source.

Evidence

  1. No 413 anywhere in the proxy's own records. Status distribution across ~/.opencodex/usage.jsonl (10k+ requests):

    200: 10766   499: 319   502: 145   429: 19   400: 6   402: 2
    413: 0
    

    service.log likewise has no 413 entry. The request never reached fetch().

  2. The bodies really are over 128 MiB. Two Codex sessions that recorded the error, measured from their rollout JSONL:

    rollout file size response_item total image-bearing tool outputs
    …17-17-29-019ffa32 243 MB 199.9 MB 33 / 185.5 MB
    …13-40-27-019ff96b 463 MB 272.6 MB 90 / 269.5 MB

    Individual custom_tool_call_output lines reach 25 MB / 9 MB / 3 MB — base64 images from Codex's built-in image_gen, replayed as conversation history on every turn. zstd request compression only recovers the base64 overhead (~75% of raw), so the wire body still lands around 150 MB and stays over the cap.

  3. Both failures occurred on sub-agent turns — the error text is followed by This agent's turn failed. If you still need this agent, use the available collaboration tools to give it another task.

Reproduction

  1. Run a Codex session that accumulates >128 MiB of conversation input (easiest: dozens of image_gen calls; any large multimodal history works).
  2. Route it through the opencodex proxy over HTTP (websockets: false).
  3. The next turn fails with unexpected status 413 Payload Too Large: Unknown error, and nothing is written to usage.jsonl or service.log.

Still present on latest

Checked the published 2.14.1 tarball. Bun.serve now takes a shared serveOptions object, which still does not set the field:

const serveOptions = {
  idleTimeout: 255,
  async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {

rg maxRequestBodySize package/src/ → no matches. MAX_DECOMPRESSED_BODY_BYTES is still 256 * 1024 * 1024.

Impact

Suggested fix

Set maxRequestBodySize on Bun.serve to at least MAX_DECOMPRESSED_BODY_BYTES, so the documented 256 MiB cap is the one that actually fires and rejections come back as a typed JSON error rather than an empty 413:

const serveOptions = {
  idleTimeout: 255,
  maxRequestBodySize: MAX_DECOMPRESSED_BODY_BYTES,
  async fetch(...) { ... }
}

Optionally make it configurable alongside appOwnedMemoryBudgetMb, and log a warning when a request is rejected for size so the operator has something to find.

Note this is distinct from #987, which concerned an upstream gateway's 10 MiB cap. This one is the local listener rejecting the request before any provider is contacted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions