Expected Responses WebSocket HTTP 426 fallback is logged at ERROR despite a successful turn
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
Expected WebSocket HTTP 426 fallback is logged as an error
Version
- Codex CLI:
0.153.4 - Platform: Linux x86_64
- Runtime reproduction: published Codex CLI
0.153.4 - Source inspection: current
openai/codexmain commit
121f91fd5d9dc66017866ce9bdc49f1e182721df
Summary
When the built-in openai provider attempts WebSocket transport and the endpoint
returns HTTP 426, Codex correctly falls back to HTTP and completes successfully.
However, the expected negotiation response is logged at ERROR:
ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 426 Upgrade Required, url: ws://127.0.0.1:<port>/v1/responses
This makes a healthy invocation appear to have failed even though the fallback
request succeeds and Codex exits with status 0.
Reproduction
This uses only a synthetic API key and a localhost Python server. It does not
contact an OpenAI account or model. The server returns HTTP 426 to the WebSocket
upgrade and a minimal successful Responses SSE stream to the HTTP fallback.
mkdir codex-426-repro
npm install --prefix codex-426-repro --no-audit --no-fund @openai/codex@0.153.4
# Save the script below as codex-426-repro/repro_426.py
python3 codex-426-repro/repro_426.py
Self-contained reproducer
#!/usr/bin/env python3
import json
import os
import subprocess
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
requests = []
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, _format, *_args):
pass
def do_GET(self):
requests.append({
"method": "GET",
"path": self.path,
"upgrade": self.headers.get("Upgrade"),
})
self.send_response(426, "Upgrade Required")
self.send_header("content-length", "0")
self.end_headers()
def do_POST(self):
size = int(self.headers.get("content-length", "0"))
self.rfile.read(size)
requests.append({
"method": "POST",
"path": self.path,
"upgrade": self.headers.get("Upgrade"),
})
response_id = "resp_local_fixture"
item = {
"id": "msg_local_fixture",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "DONE",
"annotations": [],
}],
}
completed = {
"id": response_id,
"object": "response",
"created_at": 0,
"status": "completed",
"error": None,
"incomplete_details": None,
"instructions": None,
"max_output_tokens": None,
"model": "synthetic-local-model",
"output": [item],
"parallel_tool_calls": True,
"previous_response_id": None,
"reasoning": {"effort": "none", "summary": None},
"store": False,
"temperature": None,
"text": {"format": {"type": "text"}},
"tool_choice": "auto",
"tools": [],
"top_p": None,
"truncation": "disabled",
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
"metadata": {},
}
events = [
{
"type": "response.created",
"response": dict(completed, status="in_progress", output=[]),
},
{
"type": "response.output_item.added",
"response_id": response_id,
"output_index": 0,
"item": dict(item, status="in_progress", content=[]),
},
{
"type": "response.output_text.delta",
"response_id": response_id,
"item_id": item["id"],
"output_index": 0,
"content_index": 0,
"delta": "DONE",
},
{
"type": "response.output_item.done",
"response_id": response_id,
"output_index": 0,
"item": item,
},
{"type": "response.completed", "response": completed},
]
payload = b"".join(
f"event: {event['type']}\ndata: "
f"{json.dumps(event, separators=(',', ':'))}\n\n".encode()
for event in events
) + b"data: [DONE]\n\n"
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.send_header("content-length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
with tempfile.TemporaryDirectory(prefix="codex-426-") as temporary:
root = Path(temporary)
home = root / "home"
project = root / "project"
codex_home = home / ".codex"
codex_home.mkdir(parents=True)
project.mkdir()
(codex_home / "config.toml").write_text(
f'[projects."{project}"]\ntrust_level = "trusted"\n',
encoding="utf-8",
)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
base_url = f"http://127.0.0.1:{server.server_port}/v1"
environment = {
"HOME": str(home),
"CODEX_HOME": str(codex_home),
"OPENAI_API_KEY": "synthetic-local-only",
"PATH": os.environ["PATH"],
"TERM": "dumb",
"NO_COLOR": "1",
}
binary = Path(__file__).parent / "node_modules" / ".bin" / "codex"
version = subprocess.run(
[binary, "--version"],
env=environment,
text=True,
capture_output=True,
timeout=10,
check=True,
).stdout.strip()
result = subprocess.run(
[
binary,
"-c",
f'openai_base_url="{base_url}"',
"exec",
"--dangerously-bypass-approvals-and-sandbox",
"--skip-git-repo-check",
"--model",
"synthetic-local-model",
"Return the fixture response.",
],
cwd=project,
env=environment,
stdin=subprocess.DEVNULL,
text=True,
capture_output=True,
timeout=20,
)
server.shutdown()
print(json.dumps({
"version": version,
"exit": result.returncode,
"requests": requests,
"stdout": result.stdout,
"stderr": result.stderr,
}, indent=2))
Observed request sequence:
GET /v1/responses Upgrade: websocket -> 426
POST /v1/responses -> 200 SSE completion
Observed result:
Codex version: codex-cli 0.153.4
Exit status: 0
Stdout: DONE
Stderr: contains one ERROR-level WebSocket HTTP 426 message
Root cause
The built-in openai provider enables WebSockets. In
connect_websocket,
every connector error is logged at ERROR before it is converted by
map_ws_error. The caller later recognizes handshake HTTP 426 and deliberately
falls back to HTTP in
ModelClient.
The operation therefore succeeds, but the earlier unconditional error log
remains visible.
Suggested change
Before consuming the connector error, detect only
WsError::Http(response) where response.status() == StatusCode::UPGRADE_REQUIRED
and log that expected negotiation result at DEBUG. Keep all other handshake
errors at ERROR, then pass the error through the existing map_ws_error path
unchanged. This preserves the established HTTP fallback, provider identity,
status mapping, and retry behavior.
Suggested tests should capture tracing output and verify that:
- HTTP 426 produces a debug event and no error event.
- Other HTTP statuses, such as 401, remain error events.
- Network and WebSocket protocol failures remain error events.
- The returned
ApiErrorfor each case is unchanged, including the 426 status
used by the existing fallback logic.
Related issues
- #37988 includes a 426 line in a report about credential logging; this report
is limited to severity of the expected fallback message and contains no
credential issue. - #19821 concerns timeout/retry behavior, while this reproduction completes
successfully after immediate HTTP fallback. - #15492 concerns authentication loss, while this reproduction uses a
localhost synthetic endpoint and the fallback HTTP request succeeds.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in codex-rs/codex-api/src/endpoint/responses_websocket.rs at connect_websocket and its map_ws_error path, then review the fallback handling in codex-rs/core/src/client.rs. Add focused tracing tests for HTTP 426, other HTTP statuses, network failures, and protocol failures. Done means 426 emits debug without an error event while other failures and returned ApiError values remain unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend-api-design, testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100