Skip to content

fix(proxy): fail over stuck HTTP bridge owner requests to a fresh account - #1401

Open
softkleenex wants to merge 1 commit into
Soju06:mainfrom
softkleenex:feat/http-bridge-owner-stuck-failover
Open

fix(proxy): fail over stuck HTTP bridge owner requests to a fresh account#1401
softkleenex wants to merge 1 commit into
Soju06:mainfrom
softkleenex:feat/http-bridge-owner-stuck-failover

Conversation

@softkleenex

@softkleenex softkleenex commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary (English)

Intent: operators run long, autonomous Codex turns unattended (overnight, "leave it running and go to sleep"). Today, if the upstream OpenAI/ChatGPT side — not codex-lb, not the local machine — simply stops responding to one request (a hung connection, a quota/rate edge case, transient server flakiness), the client sees:

stream disconnected before completion: codex-lb is temporarily
overloaded during http_bridge_response_create_gate

...and the turn just sits there. Nothing crashes, nothing retries — it is silently dead until a human notices and manually intervenes. This PR's whole purpose is to make that class of interruption self-heal on the proxy side, so a hung upstream connection to one account doesn't kill an unattended run.

Root cause (traced end-to-end)

  1. An HTTP bridge "owner" request is accepted upstream but the account never sends back a single response event (no response.created, no telemetry, nothing).
  2. The owner's own keepalive loop had no failover of its own — it just emitted SSE keepalives until stream_idle_timeout_seconds (default 7200s, two hours) before giving up with a terminal error.
  3. Separately, a different request waiting on that same session's gate times out after the much shorter http_responses_session_bridge_stuck_gate_retire_after_seconds (default 300s) and — only for a narrow set of requests — transparently retries on a different bridge. But if that waiter had already been reconnected once by its client (replay_count > 0), it was excluded from that retry even though replay count says nothing about whether this bridge attempt made upstream progress.

Net effect: a single flaky account can freeze a client-visible turn for anywhere from 5 minutes up to 2 hours, with no attempt to just try a different account.

What changes

def _http_bridge_owner_is_stuck(
    request_state: _WebSocketRequestState,
    *,
    yielded_any: bool,
    age_seconds: float,
    threshold_seconds: float,
) -> bool:
    # Mirrors the waiter-side stuck-gate predicate, but evaluated by the
    # owner request itself from inside its own keepalive loop. A previous-
    # response account pin makes moving accounts unsafe (continuation must
    # stay on the account that owns the prior response id), so those turns
    # are excluded here and left to the existing idle-timeout behavior.
    return (
        not yielded_any
        and request_state.response_id is None
        and request_state.response_event_count == 0
        and not request_state.downstream_visible
        and request_state.previous_response_id is None
        and age_seconds >= threshold_seconds
    )
  • The owner's keepalive loop now runs this check on every tick. Once it fires, the proxy retires the session, records the failure against the account (so it isn't immediately reselected), picks a fresh eligible account, and resubmits the exact same request on a new bridge before yielding a single byte to the client. In the common case the client never even sees a hiccup — the turn just quietly continues on a healthier account.
  • Continuity turns (previous_response_id set — i.e. this is a continuation of a prior response) are explicitly left alone. Moving those to a different account would be unsafe, so they keep the existing wait/idle-timeout behavior.
  • The narrower waiter-side replacement predicate is broadened: a non-zero client-visible replay_count no longer disqualifies an otherwise definitively-unsubmitted waiter, since replay count reflects the client's reconnect history, not upstream progress on the current bridge attempt.
  • Every other existing safeguard is untouched: any request that already has a response id, a response event, a downstream sequence number, or visible output is never silently retried.

Validation

$ uv run pytest tests/unit/test_proxy_http_bridge.py -k "owner_is_stuck or owner_stuck_gate or replacement_ignores_replay_count" -v
...
tests/unit/test_proxy_http_bridge.py::test_http_bridge_retired_gate_replacement_ignores_replay_count PASSED
tests/unit/test_proxy_http_bridge.py::test_http_bridge_owner_is_stuck_predicate[None-None-True] PASSED
tests/unit/test_proxy_http_bridge.py::test_http_bridge_owner_is_stuck_predicate[response_id-resp-owner-progressed-False] PASSED
tests/unit/test_proxy_http_bridge.py::test_http_bridge_owner_is_stuck_predicate[response_event_count-1-False] PASSED
tests/unit/test_proxy_http_bridge.py::test_http_bridge_owner_is_stuck_predicate[downstream_visible-True-False] PASSED
tests/unit/test_proxy_http_bridge.py::test_http_bridge_owner_is_stuck_predicate[previous_response_id-resp-prior-turn-False] PASSED
tests/unit/test_proxy_http_bridge.py::test_http_bridge_owner_is_stuck_predicate_requires_age_and_no_output_yielded PASSED
tests/unit/test_proxy_http_bridge.py::test_http_bridge_owner_stuck_gate_raises_after_threshold_with_no_pin PASSED
tests/unit/test_proxy_http_bridge.py::test_http_bridge_owner_stuck_gate_does_not_fire_with_continuity_pin PASSED

9 passed in 0.47s
  • uv run ruff check app tests — clean
  • uv run ruff format --check app tests — clean
  • python3 scripts/check_proxy_architecture.py — passes (no capped file exceeded)
  • uv run ty check — clean
  • uv run pytest tests/unit4127 passed, 55 skipped
  • uv run pytest tests/integration1554 passed, 8 skipped; the one unrelated failure (test_quota_planner_api.py::test_quota_planner_warm_now_keeps_bootstrap_for_metadata_less_primary_rows) was confirmed to reproduce identically on main with this diff stashed out — it is pre-existing and untouched by this PR.
  • OpenSpec change proposal included at openspec/changes/failover-stuck-http-bridge-owner/ with proposal.md, tasks.md, and a MODIFIED Requirements delta against the proxy-admission-control capability spec.

요약 (한국어)

작업 의도: 사용자들은 장시간 자율 작업(밤새 돌려놓고 자는 식)을 codex-lb를 통해 실행합니다. 그런데 지금은 codex-lb나 로컬 환경이 아니라 OpenAI/ChatGPT 업스트림 서버 쪽에서 특정 요청 하나에 응답을 아예 안 주는 상황(연결 멈춤, 할당량/레이트리밋 경계 케이스, 일시적 서버 불안정 등)이 발생하면, 클라이언트에는 이렇게 뜹니다:

stream disconnected before completion: codex-lb is temporarily
overloaded during http_bridge_response_create_gate

...그리고 그 작업은 그냥 멈춰버립니다. 크래시도 안 나고, 재시도도 안 되고 — 사람이 알아채고 직접 개입할 때까지 조용히 죽어있는 상태가 됩니다. 이 PR의 목적은 정확히 이 유형의 중단을 프록시 단에서 스스로 회복시켜서, 계정 하나의 업스트림 연결이 멈췄다고 밤새 돌려놓은 작업 전체가 멎어버리는 일이 없게 하는 것입니다.

원인 (끝까지 추적함)

  1. HTTP 브릿지의 "owner" 요청이 업스트림에 정상적으로 접수는 됐는데, 계정 쪽에서 응답 이벤트를 단 하나도 안 보내는 경우가 있습니다 (response.created도, 텔레메트리도, 아무것도 없음).
  2. 이 owner 요청 자체는 자체적인 failover가 전혀 없었어요stream_idle_timeout_seconds(기본 7200초, 2시간)까지 그냥 keepalive만 보내다가 결국 터미널 에러로 끝났습니다.
  3. 별개로, 같은 세션의 게이트를 "기다리던" 다른 요청은 훨씬 짧은 http_responses_session_bridge_stuck_gate_retire_after_seconds(기본 300초) 이후 타임아웃되고, 좁은 조건에서만 다른 브릿지로 투명하게 재시도합니다. 근데 그 대기 요청이 클라이언트 쪽에서 한 번이라도 재연결(replay_count > 0)됐던 적이 있으면, 실제로는 이 브릿지 시도 자체가 업스트림에 아무 진전도 없었는데도 그 재시도 대상에서 제외되고 있었습니다.

결과적으로: 계정 하나가 불안정하면 클라이언트가 보는 작업 턴이 짧으면 5분, 길면 2시간까지 그냥 멈춰버리고, 다른 계정으로 한번 시도해보려는 노력조차 없었습니다.

무엇을 바꿨나

  • owner 요청의 keepalive 루프가 매 tick마다 위 코드의 판정을 돌립니다. 조건이 맞으면(응답 이벤트 0개, 화면에 아무것도 안 보임, 이전 응답 연속성 고정이 없음, 임계시간 초과) 세션을 정리하고, 해당 계정에 실패 기록을 남기고(바로 재선택 안 되게), 다른 정상 계정을 골라서 클라이언트에게 단 한 바이트도 보여주기 전에 완전히 동일한 요청을 새 브릿지에서 재제출합니다. 대부분의 경우 클라이언트는 끊긴 것조차 눈치 못 채고, 그냥 더 건강한 계정으로 조용히 이어집니다.
  • previous_response_id가 있는 연속성(continuation) 턴은 명시적으로 건드리지 않습니다 — 그런 턴을 다른 계정으로 옮기는 건 안전하지 않아서, 기존의 대기/idle-timeout 동작을 그대로 유지합니다.
  • 기존의 "대기자 재시도" 조건도 넓혔습니다: 클라이언트 쪽에서 재연결했던 횟수(replay_count)가 0이 아니라는 이유만으로 재시도 대상에서 제외되던 걸 없앴습니다 — 이 값은 클라이언트의 재연결 이력일 뿐, 현재 브릿지 시도의 업스트림 진행 상태와는 무관하기 때문입니다.
  • 그 외 기존 안전장치는 전부 그대로입니다: 이미 response id, response 이벤트, downstream sequence number, 화면에 보여진 출력이 있는 요청은 절대 조용히 재시도되지 않습니다.

검증

  • uv run ruff check app tests — 통과
  • uv run ruff format --check app tests — 통과
  • python3 scripts/check_proxy_architecture.py — 통과 (파일별 줄수 상한 안 넘음)
  • uv run ty check — 통과
  • uv run pytest tests/unit4127개 통과, 55개 skip
  • uv run pytest tests/integration1554개 통과, 8개 skip. 무관한 실패 1개(test_quota_planner_api.py::test_quota_planner_warm_now_keeps_bootstrap_for_metadata_less_primary_rows)는 이 변경사항을 stash로 빼고 main에서 그대로 돌려도 동일하게 재현되는 걸 확인했습니다 — 이 PR과 무관한 기존 문제입니다.
  • openspec/changes/failover-stuck-http-bridge-owner/에 제안서(proposal.md, tasks.md) 및 proxy-admission-control 캐퍼빌리티 스펙에 대한 MODIFIED Requirements 델타를 포함했습니다.

Test plan

  • Unit: predicate coverage for the new owner-stuck check (positive/negative for every disqualifying field)
  • Unit: owner-stuck keepalive loop raises and retires when no continuity pin is present
  • Unit: owner-stuck check does not fire when a previous-response account pin is present
  • Unit: waiter replacement predicate accepts a replayed-but-otherwise-unsubmitted waiter
  • Existing regression suite (unit + integration) unaffected

@softkleenex
softkleenex force-pushed the feat/http-bridge-owner-stuck-failover branch from 38b1fd6 to f7a51cb Compare July 19, 2026 07:08
@softkleenex

Copy link
Copy Markdown
Contributor Author

Rebased onto latest `main` (`9b40f74`).

The `Lint (ruff)` job's failure (`proxy architecture check failed: service.py has 2604 lines; limit is 2600`) is a pre-existing issue on `main` itself, unrelated to this PR — this branch never touches `service.py`. Verified by checking out bare `origin/main` in a separate worktree and running `python3 scripts/check_proxy_architecture.py` directly against it with none of this PR's changes applied:

$ git worktree add /tmp/main-check origin/main
$ cd /tmp/main-check && python3 scripts/check_proxy_architecture.py
proxy architecture check failed: service.py has 2604 lines; limit is 2600

`app/modules/proxy/_service/http_bridge/streaming.py` (the only file this PR modifies with a line-count-relevant check) has no cap in `scripts/check_proxy_architecture.py`, so this PR's diff doesn't interact with the failing check at all. Flagging in case a separate fix for `service.py`'s line count is already in flight — happy to rebase again once that lands.

@softkleenex

Copy link
Copy Markdown
Contributor Author

Heads up on the failing `Lint (ruff)` / `CI Required` checks: this is a pre-existing architecture-check failure, unrelated to this PR's diff — `service.py` is already 4 lines over the `check_proxy_architecture.py` line-count gate on current `main` itself (confirmed by checking out unmodified `main` directly). Opened #1416 as a small, standalone fix for it.

@Soju06

Soju06 commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Reviewed the current head (f7a51cb). Two gaps between the spec text and the implementation:

  1. The proposal/spec says the proxy "retires the session, excludes that account for this attempt, selects a fresh eligible account" — but the failover call to _get_or_create_http_bridge_session in streaming.py never passes exclude_account_ids (the parameter already exists on main, app/modules/proxy/_service/http_bridge/mixin.py:362). Only the _handle_stream_error penalty is recorded, so the load balancer can legally re-select the exact account that just wedged for the "replacement" bridge.
  2. request_state.started_at is never reset for the replacement attempt. _http_bridge_owner_is_stuck computes age from started_at inside the keepalive loop, and the replacement stream reuses the same request_state — so on the replacement session's first keepalive tick the predicate is already true again (age >= threshold, response_event_count == 0), the fresh session gets retired, and the re-raised response_create_gate_owner_stuck escapes the failover block as a terminal client error. The failover only succeeds if the replacement yields an event before its first keepalive interval.

Direction-wise we've consolidated on the #1410 watchdog track for this failure family (see the #1394 discussion). Once that lands, please rebase this on top and rework the owner-side failover against that baseline — the replay_count relaxation in _http_bridge_can_replace_retired_gate_session looks independently useful.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has had no activity for 7 days.

It will be closed in 23 more days unless there is new activity.

If this is still relevant, please:

  • Rebase or push an update if the branch drifted
  • Address pending review feedback if there is any
  • Leave a short comment confirming it is still being worked on

Thanks for the contribution 🙏

@github-actions github-actions Bot added the stale No response from reporter; scheduled for close label Jul 31, 2026
@softkleenex

Copy link
Copy Markdown
Contributor Author

Still tracked — no action needed on my end right now. Per the 07-24 review, this is consolidating onto the #1410/#1394 watchdog track for the silent-upstream failure family; once that lands I'll rebase this PR on top and rework the owner-side failover against that baseline (keeping the replay_count relaxation in _http_bridge_can_replace_retired_gate_session that was called out as independently useful). Not stale, just waiting on the upstream dependency.

@github-actions github-actions Bot removed the stale No response from reporter; scheduled for close label Aug 1, 2026
@Soju06

Soju06 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

@softkleenex the dependency you were parked on has landed: #1394 merged to main on 08-04 (a66f793) as the canonical fix for the silent-upstream family, and #1410 was closed as superseded — its watchdog refinements (anchored 2x retire cap, upstream-activity silence clock, eventless missing-response.created deadline) are in #1394's final form. http_bridge/streaming.py was substantially reworked there, so plan on a rebase onto current main rather than a conflict fix when you rework the owner-side failover. The replay_count relaxation in _http_bridge_can_replace_retired_gate_session remains independently wanted.

@Komzpa Komzpa added the needs rebase Needs rebase or conflict repair against current main label Aug 4, 2026
Rebased onto main after Soju06#1394 ("stabilize silent and clean-close
recovery") landed its own bounded eventless response.created watchdog,
which already covers this change's original owner-side stuck-gate
failover (see recover-fresh-hard-bridge-timeouts's "Fresh hard bridge
requests may recover across accounts"). Re-scoped to what's still open
on the separate waiter-side gate-replacement path
(_http_bridge_can_replace_retired_gate_session):

- A waiter whose client already reconnected once (replay_count > 0) is
  no longer disqualified from transparent replacement on its own —
  replay count reflects client reconnects, not upstream progress on the
  current bridge attempt.
- A waiter's replacement bridge now excludes the account whose gate it
  was just waiting behind, so it can't legally land back on the exact
  account that just proved stuck.
@softkleenex
softkleenex force-pushed the feat/http-bridge-owner-stuck-failover branch from f7a51cb to 51fdddc Compare August 4, 2026 09:32
@softkleenex

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (`51fdddc2`) — and re-scoped the change substantially, so flagging clearly rather than presenting it as a quiet rebase.

Why the scope changed: this PR's original core feature — an owner-side stuck-gate detector that retires the session, excludes the account, and resubmits fresh — is now covered by `recover-fresh-hard-bridge-timeouts` (landed as part of #1394): its "Fresh hard bridge requests may recover across accounts" requirement does the same thing via a bounded eventless `response.created` watchdog, anchored to when the create request was actually sent (not `started_at`, which was one of the two gaps you found in the 07-24 review) and already excluding the failed account on retry. Re-implementing a second, parallel owner-side detector on top of that would just duplicate it. I also did not carry forward this PR's original account-penalization step (`_handle_stream_error` on the stuck account) — #1394's mechanism deliberately treats "no `response.created`" as upstream-ambiguous rather than proof the account is bad, and every one of its failure paths passes `penalize_account=False`; reintroducing penalization would contradict that design decision rather than build on it, so I left it alone.

What's left and still genuinely open turned out to be on a different, older code path — the waiter-side gate replacement (`_http_bridge_can_replace_retired_gate_session`), which #1394 didn't touch:

  1. The `replay_count` relaxation you confirmed is still wanted — done, verbatim as discussed.
  2. While rebasing I found this predicate's call site never added the retired session's account to `exclude_account_ids` before building the replacement — the same class of gap as your first 07-24 finding, just in this sibling function. Fixed: a waiter's replacement now excludes the account whose gate it was just waiting behind (continuity-pinned waiters are unaffected, they stay pinned as before).

Updated `openspec/changes/failover-stuck-http-bridge-owner` to describe this narrower scope and the supersession explicitly. Full suite (7118 passed, 3 pre-existing-and-unrelated deselected — the quota-planner flake noted on #1417 and the two `test_proxy_websocket_responses.py` failures that reproduce on a clean `upstream/main` checkout), ty check, ruff check/format, and the architecture-check script all green.

If you intended something broader by "rework the owner-side failover" than what #1394 already covers, let me know and I'll take another pass — this is my best read of what's actually still missing after reading through #1394's mechanism in detail.

@Komzpa

Komzpa commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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

Labels

needs rebase Needs rebase or conflict repair against current main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants