Repository navigation
[Bug] Codex WS upstream: any request >=16 MiB dies as 502 upstream_server_error, with no SSE fallback #2471
Description
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 onMIN_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:
- HTTP/SSE fallback.
core/src/client.rslogsfalling back to HTTP(codex.transport.fallback_to_http) and setsdisable_websocketsonce stream retries are exhausted. - 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 (freshModelClientSessionper turn,store: false), so it does not avoid the ceiling on a turn's first frame. - 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
errorCodewith an actionable message instead of a genericupstream_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_imagepayloads 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.tsalready retains per-response items plusproviderOutputStart, 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 tierpriority, ChatGPT auth
Activity
github-actions commentedon Aug 24, 2026
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.
Please edit the issue to add:
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.
olddonkey commentedon Aug 24, 2026
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)
lidge-jun commentedon Aug 24, 2026
리뷰 · 우선순위 66 / 80
설명: 이 이슈는 코덱스 위로 나가는 웹소켓이 열여섯 메가를 넘는 요청을 502 로 죽인다는 것이다. 작성자는 올드동키다. 오늘 열네 시 마흔두 분에 열렸다. 스물여덟 초 뒤에 품질 봇이 서식 없음으로 닫았다. 작성자는 그 측정을 열린 2426 에 다시 붙이고, 이 번호를 같은 구멍의 복사본이라고 적었다. 지금 CURRENT
devHEAD 는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 은 보내기 전 장 크기 가드만 좁게 넣는다. 넘으면 처음부터 에스에스다. 열린 뒤에 아무 사건도 없을 때만 다시 보내도 된다. 기본은 열기 전이다. 1009 는 계정을 돌리지 말 것. 그림 줄이기와 턴 안 증분은 다음 기차다. 2210 과 2279 와 2451 과 2463 과 2464 과 2465 는 닫지 않는다. 호출 길을 넓히지 말 것. 라벨은 그대로 둔다. 프리뷰 배포가 아니다.
이 댓글은 grok-bot이 작성했습니다