Skip to content

[Bug] Codex WS upstream: any request >=16 MiB dies as 502 upstream_server_error, with no SSE fallback #2471

Closed as not planned

Description

@olddonkey

Summary

Since 2.31.0 — the first release bundling Bun 1.4.0, which satisfies MIN_BOUNDED_CODEX_WS_BUN_VERSION — every streaming ChatGPT-Codex turn rides the responses_websockets transport. The backend closes any WS message larger than 16 MiB, and codexWsUpstreamFetch only falls back to SSE when the upgrade fails. A close after open produces a hard 502 upstream_server_error with no fallback and no actionable message.

The practical effect: once a thread's serialized request crosses 16 MiB (trivially reached by pasting ~11–12 screenshots under full replay), that thread is permanently dead — every retry re-sends the same oversized frame. Other threads on the same account keep working, so it looks like a per-conversation upstream fluke rather than a size limit.

Measured threshold (byte-exact)

Replaying a real thread's payload through the local proxy:

16,777,000 B = 16.000 MiB -> OK   (6.6s)
16,777,300 B = 16.000 MiB -> FAIL (1.0s)

The boundary is exactly 16 MiB = 16,777,216 B. 100% reproducible; failures return in ~1–2s with streamAborted: true.

This is not a Bun client-side cap. A local Bun WS server with maxPayloadLength: 512 MiB happily accepts a 20 MiB frame from the same Bun client. The limit is enforced by the receiving side (chatgpt.com), consistent with the documented upstream close code 1009 Message Too Big.

Real-world trigger

A thread with 15 pasted clipboard screenshots — ~1.4 MiB base64 each, 20.9 MiB of images, 22.6 MiB of serialized items. Under full replay every turn resends all of it, so the thread failed 100% of the time from the moment it crossed the line, while concurrent threads (~800 KB payloads) on the same account and model were unaffected.

Diagnosis is currently very hard

usage.jsonl and /api/logs only expose status: 502, errorCode: upstream_server_error, streamAborted: true. The real reason appears nowhere in opencodex's own telemetry — it is only visible in the client's rollout:

~/.codex/sessions/<date>/rollout-*.jsonl  →  task_complete.error
"stream disconnected before completion: Upstream stream terminated unexpectedly:
 codex websocket closed before a Responses terminal event"

The close handler in src/server/responses/ws-upstream.ts discards the close code and reason, so a 1009 is indistinguishable from an ordinary network drop.

Why it started now

  • WS transport landed 2026-08-12 (6f2be3514), gated on MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0".
  • 2.28.0 / 2.29.0 shipped Bun 1.3.14 → gate closed → everything went over SSE POST, which has no 16 MiB ceiling.
  • 2.31.0 (published 2026-08-22; 2.30.0 was never published to npm) is the first release with Bun 1.4.0 → gate opens → the ceiling becomes reachable for every user on that build.

How upstream Codex handles the same wall

Official Codex hits it too — openai/codex#24550 reports a hard fallback once replacement_history reached ~17.38 MB with 42 inline images — but it has three mitigations we have none of:

  1. HTTP/SSE fallback. core/src/client.rs logs falling back to HTTP (codex.transport.fallback_to_http) and sets disable_websockets once stream retries are exhausted.
  2. Within-turn incremental append. previous_response_id + get_incremental_items() over the reused socket; only deltas after the first create. Note this is strictly within one turn (fresh ModelClientSession per turn, store: false), so it does not avoid the ceiling on a turn's first frame.
  3. Client-side image budget / resize. resize_all_images, unified_image_budget, compaction_image_budget, and "default high resized behavior".

The same class of proxy bug, with the same recommended fix, is documented in router-for-me/CLIProxyAPI#4409: estimate the serialized size before opening the WS, bypass to SSE when it exceeds the limit, and do not rotate credentials on a 1009 — otherwise one oversized payload gets retried against every account in the pool.

Proposed fix, in three cycles

Cycle 1 — stop the bleeding (small, self-contained)

  • Pre-flight size check in shouldUseCodexWsUpstream / codexWsUpstreamFetch: if the serialized frame is at or above a threshold (16 MiB minus headroom), skip WS and take the SSE path directly.
  • Treat "socket opened, closed before any event arrived, nothing streamed downstream" as retryable over SSE rather than a terminal 502.
  • Capture the WS close code and reason. Map an oversized close (1009) to a distinct errorCode with an actionable message instead of a generic upstream_server_error, and suppress account rotation for that code.

Cycle 2 — bound what goes on the wire (medium)

  • Mirror the official image budget: downscale inline input_image payloads above a size/dimension threshold and cap total image bytes per request, with a resize notice. This is what actually keeps the ceiling out of reach — under full replay each pasted screenshot is permanent rent, and ~11–12 of them is enough to cross 16 MiB.

Cycle 3 — within-turn incremental append (large; performance, not correctness)

  • Pool the upstream WS per conversation across the inbound HTTP round-trips of a single turn. Send previous_response_id + delta items when the request is a strict superset of (previous input + returned items) and the non-input properties match: model, instructions, tools, tool_choice, reasoning, store, stream, include, service_tier, prompt_cache_key, text.
  • src/responses/state.ts already retains per-response items plus providerOutputStart, which is exactly the anchor this diff needs.
  • Must not reuse a socket across turns (sticky-routing token contract, per the upstream comment). Main open risk: a pooled socket pins the account, which conflicts with rotation / pacing / failover.
  • Benefit: today every tool round-trip re-uploads the entire context (~21 MiB per call in the thread above); this removes that amplification. It does not raise the 16 MiB ceiling.

Repro

import json, urllib.request, time

def probe(target_bytes):
    pad = "x" * target_bytes
    body = json.dumps({
        "model": "gpt-5.6-sol",
        "input": [{"role": "user", "content": [{"type": "input_text", "text": pad}]}],
        "stream": True, "max_output_tokens": 1500,
    }).encode()
    req = urllib.request.Request(
        "http://127.0.0.1:10100/v1/responses", data=body,
        headers={"Content-Type": "application/json", "Authorization": "Bearer local"})
    t0 = time.time(); done = False
    r = urllib.request.urlopen(req, timeout=300)
    for raw in r:
        line = raw.decode(errors="replace").strip()
        if line.startswith("data:") and line[5:].strip() not in ("", "[DONE]"):
            try: ev = json.loads(line[5:].strip())
            except Exception: continue
            if ev.get("type") == "response.completed": done = True
    print(f"{len(body)} B = {len(body)/1048576:.3f} MiB -> {'OK' if done else 'FAIL'} {time.time()-t0:.1f}s")

probe(16_700_000)   # OK
probe(16_800_000)   # FAIL — socket closed before any terminal event

(The real thread reproduced identically with 15 genuine input_image items; text padding is only for a byte-exact boundary.)

Environment

  • opencodex 2.31.0 (npm global, bundled Bun 1.4.0)
  • codex-cli 0.149.1, macOS (darwin 25.5.0)
  • model gpt-5.6-sol, service tier priority, ChatGPT auth

Activity

  1. github-actions commented on Aug 24, 2026

    @github-actions
    Contributor

    Issue closed: use an issue template

    Thanks for taking the time to submit this issue. It was closed automatically because it was not opened with a recognized issue template (Bug report, Feature request, Documentation, or Provider compatibility). Freeform and API-opened issues cannot skip this check.

    • This issue does not use a recognized issue template.

    Please edit the issue to add:

    • Open a new issue with the Bug report, Feature request, Documentation, or Provider compatibility template.
    • Or edit this issue so the body uses the template section headings for the kind of report you are filing.

    See the Contributing guide for details.

    Once the report passes the automated checks, it will be reopened automatically unless a maintainer has changed its state.

  2. olddonkey commented on Aug 24, 2026

    @olddonkey
    ContributorAuthor

    Duplicate of #2426, which reported the same WS-transport regression a day earlier. Leaving this closed; the byte-exact 16 MiB threshold and the remaining findings are posted there: #2426 (comment)

  3. lidge-jun commented on Aug 24, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 66 / 80

    설명: 이 이슈는 코덱스 위로 나가는 웹소켓이 열여섯 메가를 넘는 요청을 502 로 죽인다는 것이다. 작성자는 올드동키다. 오늘 열네 시 마흔두 분에 열렸다. 스물여덟 초 뒤에 품질 봇이 서식 없음으로 닫았다. 작성자는 그 측정을 열린 2426 에 다시 붙이고, 이 번호를 같은 구멍의 복사본이라고 적었다. 지금 CURRENT dev HEAD 는 438b9cc77 이다. 이번 시간에 origin/dev 는 그대로다. 새 머지는 없다. package.json 은 2.27.0 이다. src/config.ts 는 3238줄이다. src/runtime 폴더는 지금 HEAD 에 없다. combo-stream-preflight.ts 는 171줄이고 HEAD 에 있다. gui/src/combo-workspace-data.ts 는 589줄이다. src/providers/default-aliases.ts 와 model-presets.ts 는 아직 없다.

    하려는 말은 이렇다. 2.31.0 은 번들 번이 1.4.0 이다. 그때부터 스트리밍 코덱스 턴은 위 웹소켓을 탄다. 위는 한 장이 열여섯 메가를 넘으면 소켓을 닫는다. 오픈코덱스는 업그레이드가 실패할 때만 에스에스로 돌아간다. 열린 뒤에 닫히면 그냥 502 다. 긴 일은 그 크기를 넘기는 순간부터 영원히 죽는다. 같은 계정의 짧은 일은 산다. 작성자는 바이트 단위로 쟀다. 16777000 은 살고 16777300 은 죽는다. 경계는 16777216, 곧 열여섯 메가다. 로컬 번 서버는 스무 메가도 받는다. 한도는 번 클라이언트가 아니라 챗지피티 쪽이 막는다. 닫힘 코드는 1009 다.

    지금 HEAD 를 열었다. src/server/responses/ws-upstream.ts 82줄 shouldUseCodexWsUpstream 은 번이 되고, 주소가 맞고, 포스트이고, 본문이 글자이고, 맨 위 stream 이 참이면 위 웹소켓을 고른다. 본문 크기를 보는 칸은 없다. 71줄 fetch-helpers.ts 가 그 함수가 참이면 위 길로 보낸다. 예전 리뷰의 166줄은 파일이 137줄로 줄어든 뒤 71줄이다. 125줄은 본문에서 stream 을 지우고 type 을 response.create 로 붙인 한 장을 만든다. 204줄이 그 장을 연 소켓에 보낸다. 보내는 쪽 한도는 없다. 28줄 MAX_CODEX_WS_FRAME_BYTES 와 29줄 MAX_CODEX_WS_QUEUE_BYTES 는 받는 쪽 한도다. 넷 메가와 여덟 메가다. 나가는 열여섯 메가와 다른 칸이다. 282줄 close 처리 함수는 사건 인자를 받지 않는다. 코드와 이유를 버린다. 300줄은 열린 뒤에 끊기면 그 문장을 내고 에스에스로 안 돌아간다. src/server/index.ts 889줄 websockets 설정은 들어오는 업그레이드만 426 으로 막는다. src/config.ts 2546줄 websocketsEnabled, 2983줄 기본값 거짓이다. 위로 나가는 길은 이 칸을 안 본다. tests/ws-upstream.test.ts 는 나가는 크기 시험을 안 갖고 있다.

    작성자가 나눈 세 바퀴 중 첫 바퀴만 지금 구멍이다. 보내기 전에 장 크기를 재고 열여섯 메가보다 작을 때만 위 웹소켓을 연다. 넘으면 처음부터 에스에스다. 재는 자리는 125줄 다음이 맞다. 그 장이 실제로 나가는 글이다. 본문 원본을 재면 덧붙인 type 칸만큼 어긋난다. 열린 뒤에 아무 사건도 안 오고 아래로 한 글자도 안 보냈으면 에스에스로 다시 가는 것은 두 번 만들기가 아니다. 다만 기본은 열기 전에 고르는 것이다. 닫힘 코드 1009 는 계정 회전을 하면 안 된다. 같은 큰 장이 다음 계정마다 다시 죽는다. 둘째 바퀴 그림 줄이기와 셋째 바퀴 턴 안 증분은 나중 일이다. 한 기차에 섞지 말 것. 셋째 바퀴는 열여섯 메가 천장을 올리지 않는다.

    이 번호는 서식 없음으로 닫혀 있다. 품질 봇이 서식을 고치면 다시 열 수 있다고 했다. 다시 열지 말 것. 산 추적 이슈는 열린 2426 이다. 작성자도 2426 에 같은 측정을 붙였다. 2426 은 이미 grok-bot 리뷰가 있어서 이번 시간에 다시 쓰지 않는다. 고치는 PR 이 오면 2426 을 닫는다. 이 번호는 닫아 둔다. 2210 과 2279 와 2451 과 2463 과 2464 과 2465 를 이 이슈로 닫지 말 것. 사용자 길이로는 긴 일이 열여섯 메가에서 영원히 502 로 끝나서 66. 2426 은 64 였다. 이번 본문은 경계를 바이트로 못 박아서 한 칸 올렸다. 카탈로그 팁은 Ox Alpha x-preview-f-free + deepseek-v4-flash-vision-exp. Cursor 정적 카탈로그는 opus-4-8-fast / opus-5-fast. 2334 CursorCredentialRouter 는 여전히 src/providers/cursor-pool.ts 모듈+테스트만 있고 어댑터에 연결되지 않았다. 2332 H2 는 discovery 전용. 2320 overflow + 2342 는 이미 dev. 2188 사이드카는 이미 dev. 2361 __omit__ 는 이미 HEAD 다. src/lib/bounded-body.ts 128줄은 이미 죽은 신호에서 취소 없이 던진다. 2451 wait 허용 목록은 아직 하이픈 yield-time_ms 이다. types.ts/config.ts 가르기는 이 구멍을 바꾸지 않는다. 이 파일은 ws-upstream.ts 다. 닫고 다시 밑지 말 것.

    src/server/responses/ws-upstream.ts 라인 82 - shouldUseCodexWsUpstream 은 본문 크기를 안 본다. 조건이 맞으면 무조건 위 웹소켓이다
    src/server/responses/ws-upstream.ts 라인 30 - 번 1.4.0 부터 이 길이 켜진다. 2.31.0 이 그 첫 배포다
    src/server/responses/ws-upstream.ts 라인 125 - 실제로 나가는 장은 여기서 만든다. 크기 가드는 이 다음이 맞다
    src/server/responses/ws-upstream.ts 라인 204 - 그 장 전체를 한 번에 보낸다. 보내는 쪽 한도는 없다
    src/server/responses/ws-upstream.ts 라인 282 - close 처리가 사건 인자를 안 받는다. 1009 코드와 이유를 버린다
    src/server/responses/ws-upstream.ts 라인 300 - 열린 뒤에 위가 끊기면 그 문장을 내고 에스에스로 안 돌아간다
    src/server/responses/fetch-helpers.ts 라인 71 - 조건이 참이면 위 웹소켓 길이로 보낸다
    src/server/index.ts 라인 889 - 설정 websockets 는 들어오는 업그레이드만 끈다. 위로 나가는 길은 안 본다
    src/config.ts 라인 2983 - 그 설정 기본값은 거짓이다. 작성자 재현도 이 칸과 무관하다
    tests/ws-upstream.test.ts - 나가는 열여섯 메가 가드 시험이 없다
    이슈 2426 - 같은 구멍의 산 추적이다. 이 번호는 복사본이다. 다시 열지 말 것

    메인테이너의 판단이 필요한 지점

    • 이 번호를 다시 열지 않을지. 열지 말 것. 서식 없음으로 닫혔고 2426 이 산 추적이다
    • 고치는 PR 을 2426 에 묶을지. 묶는 편이 맞다
    • 첫 바퀴만 넣을지. 넣는 편이 맞다. 그림 줄이기와 턴 안 증분은 나중 기차다
    • 한도를 125줄 장 바이트로 잴지. 재는 편이 맞다. 본문 원본이 아니다
    • 1009 에서 계정 회전을 막을지. 막는 편이 맞다
    • 메인 2.31.0 핫픽스인지, origin/dev 에 먼저 넣을지. 지금 HEAD 에도 같은 구멍이 있다
    • 2210 과 한 이슈로 묶지 말 것. 제공자와 증상이 다르다

    너의 추천
    이 번호는 닫아 둔다. 다시 열지 말 것. 산 추적은 2426 이다. 고치는 PR 은 보내기 전 장 크기 가드만 좁게 넣는다. 넘으면 처음부터 에스에스다. 열린 뒤에 아무 사건도 없을 때만 다시 보내도 된다. 기본은 열기 전이다. 1009 는 계정을 돌리지 말 것. 그림 줄이기와 턴 안 증분은 다음 기차다. 2210 과 2279 와 2451 과 2463 과 2464 과 2465 는 닫지 않는다. 호출 길을 넓히지 말 것. 라벨은 그대로 둔다. 프리뷰 배포가 아니다.

    이 댓글은 grok-bot이 작성했습니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    streamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions