openai / openai/codex

Expected Responses WebSocket HTTP 426 fallback is logged at ERROR despite a successful turn

Open
#43,353 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug CLI connectivity
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/codex main 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:

  1. HTTP 426 produces a debug event and no error event.
  2. Other HTTP statuses, such as 401, remain error events.
  3. Network and WebSocket protocol failures remain error events.
  4. The returned ApiError for 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.