Skip to content

feat(proxy): fall back to raw HTTP on replay-safe bridge cooldown suppressions - #1890

Closed
dpearson2699 wants to merge 12 commits into
Soju06:mainfrom
dpearson2699:feature/1886-bridge-cooldown-http-fallback
Closed

feat(proxy): fall back to raw HTTP on replay-safe bridge cooldown suppressions#1890
dpearson2699 wants to merge 12 commits into
Soju06:mainfrom
dpearson2699:feature/1886-bridge-cooldown-http-fallback

Conversation

@dpearson2699

Copy link
Copy Markdown

Important

Stacked on #1886. This branch is based on feature/1885-websocket-connect-http-fallback and the diff shown here includes #1886's commits until it merges. Only the top commit (3fb7d97) is this PR's change — please review #1886 first. Once #1886 lands on main I will rebase this branch so the diff collapses to the single commit. (Native stacked PRs can't be used here: cross-fork stacks aren't supported in the public preview.)

Summary

When the HTTP responses bridge's retry circuit is cooling down, a suppressed submission whose state is provably undispatched now degrades to raw HTTP streaming instead of returning the bounded 503 ("HTTP responses session bridge is cooling down after repeated upstream timeouts; retry shortly."). Ambiguous continuations keep the bounded 503, because replaying them could execute the turn twice.

Type of change

  • feat: — new user-facing feature or capability

Linked issue: extends #1885 / stacked on #1886

OpenSpec

  • This PR includes / updates an OpenSpec change

Change directory: openspec/changes/fall-back-to-http-on-websocket-connect-failure/ (extends #1886's change folder with one requirement clause and two scenarios; the folder is unarchived while the stack is in flight)

Changes

  • Replay-safety predicate (request_submit.py): _http_bridge_cooldown_suppression_is_replay_safe mirrors the codebase's own unambiguous-boundary markers (from _http_bridge_can_replace_retired_gate_session): no client or proxy-injected continuation identity, no file account pin, response_id is None, zero response events, zero replays, no downstream sequence/visibility, and no pending response.create send. Only when every marker holds is the cooldown 503 tagged with the shared pre-submit provenance attribute.
  • Wrapper fallback (streaming.py): the raw-HTTP fallback from feat(proxy): fall back to HTTP transport when the upstream websocket is unavailable #1886 now also accepts tagged upstream_request_timeout failures (all other gates unchanged: nothing streamed, no unsettled API-key reservation, 5xx, provenance tag required).
  • Shared constant (helpers.py): _HTTP_BRIDGE_PRE_SUBMIT_FAILURE_ATTR moves to helpers.py so request_submit.py and streaming.py share one definition (same http_bridge domain, architecture check passes).

Real-world motivation

Observed live on 2026-08-22 ~22:31 UTC (single-account view, #1886 already deployed): a bridge turn died mid-stream (stream_incomplete), the circuit opened, and the Codex client's rapid resends produced a submit_retry_circuit_suppressed detail=hard_key_cooldown → 503 loop every ~2s until the cooldown expired. Those specific turns were ambiguous continuations — this PR deliberately keeps them on the bounded 503 (their replay safety is not provable, and the loop is bounded and self-clearing). What this PR fixes is the collateral damage: fresh turns (new conversations, other sessions landing on the same cooling hard key) were also 503-blocked despite being provably undispatched; they now complete over raw HTTP.

Simplicity

  • Works with zero config — no new settings; behavior only changes for provably-safe suppressed requests

Test plan

uv run pytest tests/unit/test_websocket_transport_fallback.py -q   # 23 passed
uv run pytest tests/unit/test_proxy_http_bridge.py -q              # 639 passed, 1 failed (pre-existing on main:
                                                                   #   ..._fails_closed_before_file_affinity..., missing
                                                                   #   file_account_pins table in this env)
uv run ruff check / format, python scripts/check_proxy_architecture.py, uv run ty check   # clean
npx @fission-ai/openspec validate fall-back-to-http-on-websocket-connect-failure          # valid

New coverage: the predicate is tested against every unambiguous-boundary marker individually (10 negative cases + the positive case), and the wrapper is tested for both the tagged-fallback and untagged-propagation paths.

Checklist

  • Title is in Conventional Commits format.
  • Linked the related issue / stack above.
  • Added or updated tests covering the change.
  • Ran the relevant local subset (see test plan).
  • OpenSpec change validates.
  • Simplicity gates reviewed (PRINCIPLES.md P1-P5).
  • CHANGELOG not edited by hand.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

…is unavailable

A websocket-only upstream outage currently cascades into account
unavailability: every connect timeout records a transient account error,
retrying clients drive all accounts into error backoff, and hard session
affinity then fails follow-up turns closed with
previous_response_owner_unavailable on every transport.

codex-rs activates its session-scoped HTTP transport fallback only when
the websocket handshake is rejected with HTTP 426 (rust-v0.149.0,
core/src/client.rs); in-band 5xx error events retry on the websocket
transport, so the existing in-band service-level failures never steer
clients to HTTP.

- Surface server-level transient websocket connect failures without
  recording account health or rotating accounts.
- Arm a bounded (60s) transport-failure marker on that path, cleared on
  the next successful upstream websocket connect, and deny responses
  websocket handshakes with HTTP 426 while it is armed or while
  upstream_stream_transport is pinned to http.
- Bypass the HTTP responses bridge under a pinned http transport and
  fall back from transient pre-stream bridge session-creation failures
  to raw HTTP streaming (skipped while an API-key usage reservation is
  unsettled).

Closes Soju06#1885
Address Codex review on Soju06#1886:

- Gate the connect-stage surface on failure_phase="connect" so OAuth
  refresh transport errors (and other account-scoped failures sharing the
  upstream_unavailable envelope) keep the classify-penalize-failover path
  toward healthy accounts instead of arming the instance-wide 426 marker.
  The websocket open timeout and invalid-handshake raises now carry that
  provenance alongside the existing InvalidStatus/OSError sites.
- Arm the handshake-denial marker when a websocket open exhausts the
  request budget, covering deployments whose budget is shorter than the
  open timeout where the budget-exhausted emit bypasses the failover
  decision.
- Force the HTTP upstream transport on bridged and raw responses paths
  while the marker is armed, so a sticky follow-up that the 426 denial
  moved to the HTTP route cannot resolve back onto the unavailable
  websocket upstream through the smart policy.
- Restrict the bridge raw-HTTP replay to failures carrying pre-submit
  session-creation provenance, so a post-dispatch upstream_unavailable
  can never dispatch the same turn twice.
- Move the marker into _service/streaming/transport_health.py shared by
  the websocket mixin, the responses websocket routes, and the bridge.
The budgeted websocket opener's process-network recovery branch reaches
the same budget-exhausted emit as the stalled-open branch and bypasses
the failover decision, so it must arm the handshake-denial marker too.
Only the websocket open runs inside that loop, so the provenance is
unambiguous; the outer connect-attempt wrapper also covers token
refresh and deliberately stays unarmed.
The model-replacement connect branch surfaces failures without entering
the failover decision, so a qualifying connect-phase 5xx transport
failure on the replacement open never armed the handshake-denial
marker. Extract the provenance classifier shared by both paths and arm
the marker in the forced-surface branch too.
The budgeted opener's loop also runs route resolution, whose
upstream_proxy_unavailable failures are pre-dispatch route evidence; an
exhausted recovery wait on one must not deny subsequent handshakes with
426. Reuse the shared connect-provenance classifier before arming.
The proxy architecture matrix allows http_bridge and websocket to import
only their listed domains (support among them, streaming not), so the
shared transport-failure marker belongs in _service/support.py rather
than a new streaming module. Also align the test harness override of
_handle_websocket_connect_error with the base signature for ty.
An exhausted token-refresh loop surfaces the same pre-submit 502
upstream_unavailable envelope as a websocket-open failure, but it is
account evidence: replaying it over raw HTTP re-runs the same failing
refresh and buries the actionable error under no_accounts (caught by
test_v1_responses_http_bridge_transient_refresh_failure_returns_
upstream_error in integration-bridge CI). Gate the wrapper fallback on
failure_phase == "connect", mirroring the websocket-side provenance
rule, and record the refresh case as a propagation scenario in the
OpenSpec delta.
@dpearson2699
dpearson2699 force-pushed the feature/1886-bridge-cooldown-http-fallback branch from 8a6bc15 to 80021f4 Compare August 24, 2026 21:21
@Komzpa

Komzpa commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80021f4f43

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/modules/proxy/_service/http_bridge/request_submit.py Outdated
Comment thread app/modules/proxy/_service/websocket/mixin.py Outdated
Comment thread app/modules/proxy/_service/websocket/mixin.py Outdated
@Komzpa Komzpa added the 🤖 codex: needs work [@codex review] raised an issue label Aug 25, 2026
The sanitized error code cannot carry transport provenance in either
direction, so classifying on it both missed real outages and captured
scoped failures:

- The responses policy preserves the upstream handshake body, so a direct
  5xx upgrade rejection surfaced as `upstream_error` and never armed the
  426 marker — the common direct outage left Codex clients retrying
  websocket indefinitely.
- A routed 5xx handshake, a TLS verification failure and host-wide network
  loss all share the `upstream_unavailable` envelope, so each was
  misclassified as a global outage and pushed unrelated clients onto HTTP.

The direct upstream open now stamps host-scoped transport provenance and
withholds it from credential-scoped rejections, TLS failures, host-wide
network loss and every routed open; the shared classifier moves to the
support domain and keys on that provenance.

Also:

- A request budget shorter than the local websocket-connect admission wait
  expired before the connector ever ran and still armed the marker,
  answering local contention by forcing every client onto HTTP. The
  budgeted opener now tracks whether the connector began.
- Bridge session creation runs its own pre-dispatch failover and never
  reaches the websocket failover decision, so bridge-only traffic left the
  marker clear. The bridge fallback now arms it for failures the classifier
  recognizes.
- The bridge injects the durable anchor into its own prepared payload, but
  the fallback replays the incoming payload and the raw path never injects
  a response anchor. A hard-continuity follow-up therefore lost prior
  context; such turns are no longer replayed over raw HTTP.

Regression coverage exercises the real client conversion for each connect
site and the real bridge reconstruction for the anchor provenance.
@dpearson2699
dpearson2699 force-pushed the feature/1886-bridge-cooldown-http-fallback branch from 80021f4 to 215ab87 Compare August 25, 2026 22:32
…paths

Two places still decided a websocket outage by something other than the
connect-site provenance the classifier now carries:

- The bridge fallback wrapper still compared the sanitized code against
  `upstream_unavailable`. A direct 5xx bridge connect preserves the upstream
  envelope, so it surfaces as `upstream_error` and was rethrown before ever
  reaching the classifier — the exact outage this change targets stayed on
  the dead websocket bridge and left the marker clear. The wrapper now gates
  on the classifier, which subsumes the code, phase and status checks.
- The budget-timeout marker keyed on any connector having begun. A stalled
  routed open therefore armed a process-wide denial from one account's
  unhealthy proxy endpoint, and because the budget cancels the open rather
  than failing it, no error exists for the routed exclusion to act on. The
  progress flag is now confined to the direct connector.
@dpearson2699
dpearson2699 force-pushed the feature/1886-bridge-cooldown-http-fallback branch from 215ab87 to ce7d7f7 Compare August 26, 2026 04:35
dpearson2699 and others added 2 commits August 26, 2026 05:57
Arming became direct-scoped, but clearing did not: any successful open,
routed included, cleared the denial state. By this change's own provenance
argument a routed success proves only that one account's proxy endpoint is
healthy, so a routed success during a direct-upstream outage readmitted
handshakes early and cost an extra failed direct attempt before the marker
re-armed.

Clearing is now direct-scoped too. Because a routed open can neither arm nor
clear, an all-routed deployment never uses the marker at all, and a mixed one
still expires it on the bounded window.
…pressions

The bridge retry circuit's pre-dispatch submission gate suppresses new
turns on a cooling hard-affinity session with a bounded 503. For a
request whose state is provably undispatched — no client or
proxy-injected continuation identity, no file account pin, and none of
the unambiguous-boundary markers (response_id, response events,
downstream visibility, a pending response.create send, a prior replay)
— that 503 now carries the shared pre-submit provenance and the
streaming wrapper degrades the turn to raw HTTP instead. Ambiguous
continuations keep the bounded 503 with its retry hint, because a
replay could execute the turn twice.

The pre-submit provenance attribute moves to http_bridge/helpers.py so
request_submit.py and streaming.py share one definition.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖 codex: needs work [@codex review] raised an issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants