Skip to content

fix(http-bridge): retire denied anchors without redispatch - #1904

Closed
JustYannicc wants to merge 10 commits into
Soju06:mainfrom
JustYannicc:fix/http-bridge-denied-anchor-retirement
Closed

fix(http-bridge): retire denied anchors without redispatch#1904
JustYannicc wants to merge 10 commits into
Soju06:mainfrom
JustYannicc:fix/http-bridge-denied-anchor-retirement

Conversation

@JustYannicc

@JustYannicc JustYannicc commented Aug 24, 2026

Copy link
Copy Markdown

Summary

This is the focused replacement for stale #1879. It extracts the denied
proxy-managed previous_response_id lifecycle concern from the much larger
#1867 repair stack, so it can be reviewed and merged as one coherent fix.

Problem

When upstream rejects a previous_response_id that codex-lb injected, the HTTP
bridge can keep the dead anchor addressable and re-inject it into later turns.
That can trim away the client's usable full-resend context, produce repeated
Invalid previous_response_id failures, and feed retry-circuit 503 responses.
Concurrent completion, reconnect, absent-session capture, owner handoff, and
stream cancellation make the retirement boundary race-prone. The prior #1879
head is stale; this branch is rebuilt on the current upstream main and keeps
this concern separate from the remaining #1867 recovery work.

What this fixes

  • Retires only a matching proxy-injected anchor after an explicit upstream
    previous_response_not_found denial.
  • Fences prepared requests by denied-anchor generation so a known-invalid id is
    rejected before another upstream dispatch.
  • Serializes tombstone publication with final request revalidation and send.
  • Preserves sibling completions, turn-state aliases, and newer owner epochs.
  • Tracks owner-scoped fence slots and retains positive generations while
    prepared requests still pin them.
  • Cleans stale owner slots on session/epoch handoff and retries transient
    durable cleanup under the original owner fence with a bounded retry budget.
  • Closes the inner async stream when the HTTP bridge wrapper is cancelled.
  • Adds OpenSpec requirements and unit/integration regressions for these races.

The downstream denial contract remains unchanged: the denied turn is still a
502 stream_incomplete; the proxy does not speculate or redispatch it.

What is now possible

  • After a verified full-context resend is rejected for a proxy-injected anchor,
    the next turn can proceed without carrying that rejected anchor forward.
  • Prepared concurrent requests fail closed instead of dispatching a stale
    previous_response_id.
  • A successor durable owner can continue using its own current fence while
    predecessor cleanup retires only stale unpinned state.
  • Cancelling a streamed response reliably closes its inner stream and retires
    the associated bridge request.

Type of change

  • fix: — bug fix (no behavior change beyond the bug)
  • feat: — new user-facing feature or capability
  • refactor: — internal refactor
  • Breaking change

Linked issue: Refs #1852. This is a focused partial fix and does not claim to
close #1852 or absorb all of #1867.

OpenSpec

  • This PR includes / updates an OpenSpec change
  • This PR touches a codex-faithful path and preserves upstream-equivalent
    request/response behavior

Change directory: openspec/changes/invalidate-denied-bridge-anchor/

Changes

  • HTTP bridge denied-anchor retirement and lifecycle fencing.
  • Durable owner/epoch cleanup and bounded retry handling.
  • Cancellation-safe stream wrapper cleanup.
  • Focused regression coverage and OpenSpec delta requirements.

Simplicity

  • No new setting or default.
  • No new required setup step.
  • No README, .env.example, dashboard navigation, schema, or migration
    surface.

Test plan

Focused lifecycle and dispatch regressions on exact head 24dd93c83:

uv run pytest -q tests/unit/test_proxy_http_bridge.py -k denied_anchor
9 passed, 651 deselected

uv run pytest -q tests/unit/test_proxy_http_bridge.py -k \
  'submit_rejects_a_denied_proxy_anchor_before_upstream_dispatch or denied_anchor_publication_serializes_with_prepared_dispatch'
2 passed, 658 deselected

uv run pytest -q tests/integration/test_http_responses_bridge.py::test_v1_responses_http_bridge_stream_cancel_retires_session
1 passed

uv run pytest -q tests/unit/test_proxy_http_bridge.py tests/integration/test_http_responses_bridge.py
791 passed, 1 known upstream-baseline failure

uv run ruff check <touched files>
All checks passed

uv run ruff format --check <touched files>
5 files already formatted

uv run ty check
All checks passed

uv run python scripts/check_proxy_architecture.py
proxy architecture checks passed

git diff --check
passed

The one broad-suite failure is the pre-existing
test_stream_via_http_bridge_fails_closed_before_file_affinity_when_previous_response_owner_misses;
it fails identically on the predecessor because the fixture database lacks the
file_account_pins table. The cancellation regression that previously failed
on this candidate now passes.

Candidate

  • Base: b311aea760aa639fd96f63bd118f775e9b4a89f9
  • Exact head: 24dd93c83b7f80fb5888f61e6bd711d90c9b95a0
  • Exact tree: 100a4ef9a9c01c200d51ad22a37deccab14751f8
  • Fork branch: JustYannicc/codex-lb:fix/http-bridge-denied-anchor-retirement

Checklist

  • Title is in Conventional Commits format.
  • Linked the related issue above.
  • Added unit and integration regression coverage.
  • Ran the relevant local checks.
  • Strict OpenSpec CLI validation — unavailable in this environment; the
    change directory and requirements are included for hosted validation.
  • Simplicity gates P1-P5 reviewed.
  • CHANGELOG is not edited by hand.

Summary by CodeRabbit

  • Bug Fixes

    • Rejected proxy-injected response anchors are now retired promptly, preventing repeated failures on subsequent requests.
    • Requests detect stale or denied anchors before dispatch and return a consistent 502 stream_incomplete error.
    • Recovery and retry flows preserve complete request data while avoiding reuse of invalid anchors.
    • Session ownership changes and cleanup now prevent stale anchor state from affecting later requests.
  • Tests

    • Added comprehensive unit and integration coverage for anchor rejection, retries, races, cleanup, and recovery behavior.

kevinsslin and others added 10 commits August 24, 2026 18:18
An upstream `previous_response_not_found` against a proxy-injected
`previous_response_id` is a verdict about the anchor, but nothing acts on it.
Anchor poisoning only scores reader failures whose detail is `stream_incomplete`
or `stream_idle_timeout`, and a denial arrives as a terminal upstream event, so
it contributes nothing at any poison threshold. The dead id therefore survives
in the durable row and in the session, the fresh-reattach path injects it into
the next turn, and the store-context trim strips the resent history against it.
Upstream then receives a suffix of the conversation behind an id it has already
refused, never emits `response.created`, and the attempt presents as an
eventless failure. Two of those open the retry circuit and the client gets a
503.

Retire the anchor on the first denial instead, clearing the durable continuity
record and the in-memory anchor together, and skip it when a sibling request has
already advanced the anchor past the denied id. Client-supplied anchors are left
alone.

Also carry `proxy_injected_previous_response_id` onto the anchored recovery
retry state. Without it a denial of the replayed anchor is not attributable to
the proxy, so the retirement above cannot fire on the path that needs it most.
The same gap reports `previous_response_source=client_supplied` for ids no
client sent and keeps `_http_bridge_request_state_wedged_reattach` from
recognising the reattach shape it exists to catch.

No new dispatch is added: the following turn is the client's own, with the
history the client sends, so no forked child response can be created against a
parent this proxy cannot observe. The downstream contract is unchanged, so
clients keep their anchor and are not driven into a full-history resend.

Refs Soju06#1852

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up on three real gaps.

Restrict retirement to anchors injected onto a full-resend-shaped payload.
A delta-only request has no other way to convey prior context once its anchor
is gone, which is the rule the expired-anchor path already applies before it
clears durable continuity. Carry the companion
`proxy_injected_anchor_had_full_resend_payload` flag onto the anchored recovery
retry state alongside the provenance flag, so a replayed anchor keeps the shape
that decides whether it may be retired.

Retire the anchor from the grouped fan-out branch too. When one denial settles
several requests sharing an anchor, that branch returns before the
single-request path, so the shared anchor survived exactly the fan-out failure.

Make retirement best-effort. It is bookkeeping, and a failure must not change
how the denial reaches the client.

Also record in the spec what the implementation actually guarantees: the
durable clear is attempted and the in-memory clear is unconditional, because
dropping one carrier strictly reduces the ways a denied id can come back. An
unconfirmed durable clear is not reported as a retirement, and the surviving
durable record re-injects the id on a later turn, which is denied again and
re-enters this path.

Refs Soju06#1852

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d72da07a-710b-4907-89d0-131bb960dc9c

📥 Commits

Reviewing files that changed from the base of the PR and between b311aea and 24dd93c.

📒 Files selected for processing (15)
  • app/modules/proxy/_service/http_bridge/helpers.py
  • app/modules/proxy/_service/http_bridge/protocol.py
  • app/modules/proxy/_service/http_bridge/request_submit.py
  • app/modules/proxy/_service/http_bridge/session_registry.py
  • app/modules/proxy/_service/http_bridge/streaming.py
  • app/modules/proxy/_service/http_bridge/upstream_events.py
  • app/modules/proxy/_service/support.py
  • app/modules/proxy/durable_bridge_coordinator.py
  • app/modules/proxy/durable_bridge_repository.py
  • openspec/changes/invalidate-denied-bridge-anchor/.openspec.yaml
  • openspec/changes/invalidate-denied-bridge-anchor/proposal.md
  • openspec/changes/invalidate-denied-bridge-anchor/specs/responses-api-compat/spec.md
  • openspec/changes/invalidate-denied-bridge-anchor/tasks.md
  • tests/integration/test_http_responses_bridge.py
  • tests/unit/test_proxy_http_bridge.py

📝 Walkthrough

Walkthrough

The HTTP bridge now retires proxy-injected response anchors after upstream denial. It records bounded process-local denial fences, clears matching durable state, prevents denied-anchor dispatch, and preserves provenance across anchored recovery retries.

Changes

Denied Bridge Anchor Retirement

Layer / File(s) Summary
Anchor denial contracts and state
app/modules/proxy/_service/http_bridge/protocol.py, app/modules/proxy/_service/support.py, openspec/changes/invalidate-denied-bridge-anchor/*
Defines denial-fence state, request metadata, session metadata, protocol operations, and retirement requirements.
Process-local denial-fence lifecycle
app/modules/proxy/_service/http_bridge/helpers.py, app/modules/proxy/_service/http_bridge/streaming.py
Tracks generations, request pins, owner epochs, anchor provenance, and guaranteed fence release during streaming cleanup.
Durable anchor retirement
app/modules/proxy/durable_bridge_*.py, app/modules/proxy/_service/http_bridge/session_registry.py, app/modules/proxy/_service/http_bridge/upstream_events.py
Conditionally clears matching durable anchors, removes aliases, resets continuity state, and retries failed cleanup.
Dispatch fencing and recovery propagation
app/modules/proxy/_service/http_bridge/request_submit.py, app/modules/proxy/_service/http_bridge/streaming.py
Rejects denied or advanced injected anchors with 502 stream_incomplete and preserves provenance only for anchored retries.
Retirement and race regression coverage
tests/unit/test_proxy_http_bridge.py, tests/integration/test_http_responses_bridge.py
Covers denial races, cleanup fencing, generation retention, recovery behavior, and non-reuse of rejected anchors.

Estimated code review effort: 5 (Critical) | ~90 minutes

Suggested reviewers: soju06, komzpa

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@JustYannicc

Copy link
Copy Markdown
Author

Duplicate filing of #1902 from the same exact reviewed head. Closing this copy so review stays on one canonical PR.

@JustYannicc
JustYannicc deleted the fix/http-bridge-denied-anchor-retirement branch August 24, 2026 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants