fix(codex): fail open for proxy compression timeout

This commit is contained in:
ashishpatel26 2026-06-03 15:44:51 +05:30
parent 378d77e79d
commit e8ecd08829
4 changed files with 88 additions and 5 deletions

View file

@ -2811,7 +2811,11 @@ class OpenAIHandlerMixin:
decide_compression_failure_action,
)
_http_action = decide_compression_failure_action(_e, _http_body_bytes)
_http_action = decide_compression_failure_action(
_e,
_http_body_bytes,
client=client,
)
if _http_action.refuse:
logger.error(
"[%s] /v1/responses REFUSING to forward request "
@ -3979,7 +3983,11 @@ class OpenAIHandlerMixin:
decide_compression_failure_action,
)
_ws_action = decide_compression_failure_action(_ce, _ws_frame_bytes)
_ws_action = decide_compression_failure_action(
_ce,
_ws_frame_bytes,
client=client,
)
if _ws_action.refuse:
logger.error(
"[%s] WS /v1/responses REFUSING to forward "

View file

@ -794,7 +794,8 @@ class CompressionFailureAction:
reason: str
"""Short machine-readable label for telemetry. One of:
``timeout``, ``oversize:bytes=<n>>threshold=<m>``,
``small_frame_transient``, or ``env_override:fail_open``."""
``small_frame_transient``, ``client_override:codex``, or
``env_override:fail_open``."""
frame_bytes: int
"""Original frame size in bytes (for logging / metrics)."""
@ -803,6 +804,8 @@ class CompressionFailureAction:
def decide_compression_failure_action(
exception: BaseException,
frame_bytes: int,
*,
client: str | None = None,
) -> CompressionFailureAction:
"""Decide whether to refuse-and-close vs forward-original after the
proxy's compression pipeline fails on a Realtime WebSocket frame
@ -812,6 +815,10 @@ def decide_compression_failure_action(
* env :data:`WS_COMPRESSION_FAIL_OPEN_ENV` truthy forward (legacy
behaviour, opt-in for debugging or strict compatibility).
* Codex client compression timeout forward. Codex currently treats
the proxy's 1009/413 refusal path as a hard connection failure, so
fail-open is safer for Codex sessions even when the proxy is run
standalone rather than through ``headroom wrap codex``.
* exception is :class:`asyncio.TimeoutError` refuse (the compression
stage hit its own timeout, which only fires on frames Headroom
thought were big enough to need compression in the first place).
@ -834,6 +841,15 @@ def decide_compression_failure_action(
frame_bytes=frame_bytes,
)
if (client or "").strip().lower() == "codex" and isinstance(
exception, asyncio.TimeoutError
):
return CompressionFailureAction(
refuse=False,
reason="client_override:codex",
frame_bytes=frame_bytes,
)
threshold = WS_COMPRESSION_OVERSIZE_BYTES_DEFAULT
raw_threshold = os.environ.get(WS_COMPRESSION_OVERSIZE_BYTES_ENV, "").strip()
if raw_threshold:

View file

@ -340,6 +340,40 @@ def test_handle_openai_responses_memory_timeout_fails_open(monkeypatch):
assert body.get("instructions") is None
def test_codex_responses_timeout_fails_open_in_standalone_proxy(monkeypatch):
"""Codex users running only the proxy still get fail-open on timeout."""
request = _build_request(
{
"model": "gpt-5.4",
"input": [
{
"type": "function_call_output",
"call_id": "call-1",
"output": "large tool output",
}
],
},
{"Authorization": "Bearer sk-test", "x-client": "codex"},
)
handler = _DummyOpenAIHandler()
handler.config.optimize = True
monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer())
monkeypatch.setattr(
handler,
"_compress_openai_responses_payload",
lambda *args, **kwargs: (_ for _ in ()).throw(TimeoutError()),
)
response = anyio.run(handler.handle_openai_responses, request)
assert response.status_code == 200
assert handler.captured_request is not None
_, url, _, body = handler.captured_request
assert url == "https://api.openai.com/v1/responses"
assert body["input"][0]["output"] == "large tool output"
class _DummyWebSocket:
def __init__(self, headers: dict[str, str]):
self.headers = headers

View file

@ -49,8 +49,8 @@ def _env(**overrides: str | None) -> Iterator[None]:
os.environ[key] = prior
def test_timeout_always_refuses_regardless_of_frame_size() -> None:
"""asyncio.TimeoutError → refuse, even for a tiny frame.
def test_timeout_refuses_without_client_override_regardless_of_frame_size() -> None:
"""asyncio.TimeoutError → refuse, even for a tiny non-Codex frame.
Compression timeout fires after ``COMPRESSION_TIMEOUT_SECONDS``, which
means the pipeline already started work on the frame. A small frame
@ -64,6 +64,31 @@ def test_timeout_always_refuses_regardless_of_frame_size() -> None:
assert action.frame_bytes == 128
def test_codex_client_timeout_fails_open_without_env_override() -> None:
"""Codex direct-proxy traffic should keep flowing on compression timeout."""
with _env(**{WS_COMPRESSION_FAIL_OPEN_ENV: None, WS_COMPRESSION_OVERSIZE_BYTES_ENV: None}):
action = decide_compression_failure_action(
asyncio.TimeoutError(),
frame_bytes=128,
client="codex",
)
assert action.refuse is False
assert action.reason == "client_override:codex"
assert action.frame_bytes == 128
def test_non_codex_timeout_still_refuses_without_env_override() -> None:
"""The Codex override must not restore global fail-open behavior."""
with _env(**{WS_COMPRESSION_FAIL_OPEN_ENV: None, WS_COMPRESSION_OVERSIZE_BYTES_ENV: None}):
action = decide_compression_failure_action(
asyncio.TimeoutError(),
frame_bytes=128,
client="claude-code",
)
assert action.refuse is True
assert action.reason == "timeout"
def test_small_transient_error_falls_through_to_passthrough() -> None:
"""Non-timeout error on a small frame: forward original (legacy)."""
with _env(**{WS_COMPRESSION_FAIL_OPEN_ENV: None, WS_COMPRESSION_OVERSIZE_BYTES_ENV: None}):