diff --git a/.all-contributorsrc b/.all-contributorsrc
index cd7180289b..b1bbcb8f86 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1082,6 +1082,25 @@
"contributions": [
"code"
]
+ },
+ {
+ "login": "shaqman",
+ "name": "Syakur Rahman",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1113851?v=4",
+ "profile": "http://expressthisout.com/",
+ "contributions": [
+ "code",
+ "test"
+ ]
+ },
+ {
+ "login": "cigro-manager",
+ "name": "cigro-manager",
+ "avatar_url": "https://avatars.githubusercontent.com/u/219247995?v=4",
+ "profile": "https://github.com/cigro-manager",
+ "contributions": [
+ "code"
+ ]
}
],
"contributorsPerLine": 7,
diff --git a/README.md b/README.md
index 17d995d0d6..b70dae66bd 100644
--- a/README.md
+++ b/README.md
@@ -265,6 +265,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/e
 alchemistkiv 💻 ⚠️ |
 mhooooo 💻 ⚠️ |
 crowscc 💻 |
+  Syakur Rahman 💻 ⚠️ |
diff --git a/app/core/clients/proxy.py b/app/core/clients/proxy.py
index eb8251bad3..1428184190 100644
--- a/app/core/clients/proxy.py
+++ b/app/core/clients/proxy.py
@@ -477,6 +477,7 @@ def __init__(
upstream_status_code: int | None = None,
upstream_error_code: str | None = None,
failed_session: aiohttp.ClientSession | None = None,
+ retry_after_seconds: int | None = None,
) -> None:
super().__init__(f"Proxy response error ({status_code})")
self.status_code = status_code
@@ -488,6 +489,7 @@ def __init__(
self.upstream_status_code = upstream_status_code
self.upstream_error_code = upstream_error_code
self.failed_session = failed_session
+ self.retry_after_seconds = retry_after_seconds
def _process_network_failure_error(
diff --git a/app/core/clients/proxy_websocket.py b/app/core/clients/proxy_websocket.py
index 06eb2d5fbb..0cec919728 100644
--- a/app/core/clients/proxy_websocket.py
+++ b/app/core/clients/proxy_websocket.py
@@ -138,6 +138,24 @@ def normalize_realtime_call_id(value: str) -> str | None:
return normalized.lower()
+def _consume_connection_lost_exception(done: asyncio.Future[Any]) -> None:
+ """Retrieve close exceptions before websockets shields the waiter.
+
+ websockets 16 waits on ``connection_lost_waiter`` through
+ ``asyncio.shield`` while completing ``ClientConnection.recv``. A peer
+ keepalive/protocol close therefore leaves an exception on the waiter,
+ which asyncio reports as an ``exception in shielded future`` even though
+ ``recv`` translates it into an ``UpstreamWebSocketMessage``. Consume it
+ at the adapter boundary; ``receive`` still classifies the close normally.
+ """
+ if done.cancelled():
+ return
+ try:
+ done.exception()
+ except asyncio.CancelledError:
+ return
+
+
@dataclass(slots=True)
class UpstreamWebSocketMessage:
kind: str
@@ -170,7 +188,14 @@ def _relay_receive_error_code(error_code: str) -> str | None:
# Relay owners map an absent code to their established stream_incomplete
# contract. Leaking the adapter's generic fallback would bypass that path.
- return error_code if error_code == PROCESS_NETWORK_UNAVAILABLE_CODE else None
+ return error_code if error_code in {PROCESS_NETWORK_UNAVAILABLE_CODE, "upstream_keepalive_timeout"} else None
+
+
+def _is_keepalive_timeout_close(exc: ConnectionClosedError) -> bool:
+ """Classify peer/proxy heartbeat failures without exposing socket details."""
+
+ reason = _close_reason_from_exception(exc)
+ return "keepalive ping timeout" in f"{exc} {reason or ''}".lower()
async def _rotate_after_websocket_network_failure(error_code: str) -> None:
@@ -223,6 +248,9 @@ def __init__(
self._connection = connection
self._uses_proxy = uses_proxy
self._preserve_close_semantics = preserve_close_semantics
+ connection_lost_waiter = getattr(connection, "connection_lost_waiter", None)
+ if isinstance(connection_lost_waiter, asyncio.Future):
+ connection_lost_waiter.add_done_callback(_consume_connection_lost_exception)
async def send_text(self, text: str) -> None:
try:
@@ -254,6 +282,11 @@ async def receive(self) -> UpstreamWebSocketMessage:
)
error_code = _websocket_transport_error_code(exc, uses_proxy=self._uses_proxy)
await _rotate_after_websocket_network_failure(error_code)
+ relay_error_code = (
+ "upstream_keepalive_timeout"
+ if _is_keepalive_timeout_close(exc)
+ else _relay_receive_error_code(error_code)
+ )
# ConnectionClosedError describes an incomplete close handshake,
# not generic transport provenance. Let Responses relay owners map
# it to stream_incomplete while live relays preserve received closes.
@@ -265,7 +298,7 @@ async def receive(self) -> UpstreamWebSocketMessage:
if self._preserve_close_semantics
else str(exc)
),
- error_code=_relay_receive_error_code(error_code),
+ error_code=relay_error_code,
)
except Exception as exc:
error_code = _websocket_transport_error_code(exc, uses_proxy=self._uses_proxy)
diff --git a/app/core/config/settings.py b/app/core/config/settings.py
index 0806512ac5..9df0a0bf94 100644
--- a/app/core/config/settings.py
+++ b/app/core/config/settings.py
@@ -299,6 +299,11 @@ class Settings(BaseSettings):
http_responses_session_bridge_stuck_gate_retire_after_seconds: float = Field(default=300.0, gt=0)
http_responses_session_bridge_max_sessions: int = Field(default=256, gt=0)
http_responses_session_bridge_queue_limit: int = Field(default=8, gt=0)
+ http_responses_session_bridge_clean_close_retry_jitter_max_seconds: float = Field(
+ default=2.0,
+ ge=0,
+ le=30.0,
+ )
http_responses_session_bridge_gateway_safe_mode: bool = False
http_responses_session_bridge_instance_id: str = Field(default_factory=_default_http_bridge_instance_id)
http_responses_session_bridge_instance_ring: Annotated[list[str], NoDecode] = Field(default_factory=list)
diff --git a/app/core/metrics/prometheus.py b/app/core/metrics/prometheus.py
index dbc75f79f9..42a647e3df 100644
--- a/app/core/metrics/prometheus.py
+++ b/app/core/metrics/prometheus.py
@@ -172,6 +172,18 @@ def labels(self, *args: str, **kwargs: str) -> "HistogramLike": ...
["strength"],
registry=REGISTRY,
)
+ bridge_handoff_compatibility_rejection_total = Counter(
+ "codex_lb_bridge_handoff_compatibility_rejection_total",
+ "Total closed HTTP bridge admission handoffs rejected for incompatible request context",
+ ["continuity_anchor", "preferred_account", "service_tier", "api_key_scope"],
+ registry=REGISTRY,
+ )
+ bridge_unanchored_handoff_recovery_total = Counter(
+ "codex_lb_bridge_unanchored_handoff_recovery_total",
+ "Total stale closed HTTP bridge admission handoffs recovered without a continuity anchor",
+ ["reason"],
+ registry=REGISTRY,
+ )
bridge_local_rebind_total = Counter(
"codex_lb_bridge_local_rebind_total",
"Total bridge local rebinds by reason",
@@ -264,6 +276,24 @@ def labels(self, *args: str, **kwargs: str) -> "HistogramLike": ...
["reason", "affinity_kind", "model_class"],
registry=REGISTRY,
)
+ http_bridge_retry_circuit_total = Counter(
+ "codex_lb_http_bridge_retry_circuit_total",
+ "Total HTTP bridge automatic retry circuit outcomes",
+ ["outcome"],
+ registry=REGISTRY,
+ )
+ stream_keepalive_sent_total = Counter(
+ "codex_lb_stream_keepalive_sent_total",
+ "Total downstream SSE keepalive frames emitted by surface",
+ ["surface"],
+ registry=REGISTRY,
+ )
+ stream_idle_timeout_total = Counter(
+ "codex_lb_stream_idle_timeout_total",
+ "Total streams terminated after exceeding the configured idle window",
+ ["surface"],
+ registry=REGISTRY,
+ )
cache_invalidation_bump_failures_total = Counter(
"codex_lb_cache_invalidation_bump_failures_total",
"Total cache invalidation version bumps that failed after retries",
@@ -315,6 +345,8 @@ def mark_process_dead() -> None:
bridge_first_turn_timeout_total: CounterLike | None = None
bridge_drain_recovery_allowed_total: CounterLike | None = None
bridge_owner_mismatch_total: CounterLike | None = None
+ bridge_handoff_compatibility_rejection_total: CounterLike | None = None
+ bridge_unanchored_handoff_recovery_total: CounterLike | None = None
bridge_local_rebind_total: CounterLike | None = None
bridge_forward_latency_seconds: HistogramLike | None = None
bridge_public_contract_error_total: CounterLike | None = None
@@ -329,6 +361,9 @@ def mark_process_dead() -> None:
proxy_phase_latency_seconds: HistogramLike | None = None
http_bridge_prewarm_total: CounterLike | None = None
http_bridge_stuck_retire_total: CounterLike | None = None
+ http_bridge_retry_circuit_total: CounterLike | None = None
+ stream_keepalive_sent_total: CounterLike | None = None
+ stream_idle_timeout_total: CounterLike | None = None
cache_invalidation_bump_failures_total: CounterLike | None = None
cache_invalidation_poll_failures_total: CounterLike | None = None
@@ -355,6 +390,7 @@ def mark_process_dead() -> None:
"bridge_durable_recover_total",
"bridge_drain_recovery_allowed_total",
"bridge_first_turn_timeout_total",
+ "bridge_handoff_compatibility_rejection_total",
"bridge_local_rebind_total",
"bridge_owner_forward_total",
"bridge_owner_mismatch_total",
@@ -363,6 +399,7 @@ def mark_process_dead() -> None:
"bridge_reattach_total",
"bridge_same_account_takeover_total",
"bridge_soft_local_rebind_total",
+ "bridge_unanchored_handoff_recovery_total",
"cache_invalidation_bump_failures_total",
"cache_invalidation_poll_failures_total",
"cap_partition_replicas",
@@ -370,7 +407,10 @@ def mark_process_dead() -> None:
"continuity_fail_closed_total",
"continuity_owner_resolution_total",
"http_bridge_prewarm_total",
+ "http_bridge_retry_circuit_total",
"http_bridge_stuck_retire_total",
+ "stream_keepalive_sent_total",
+ "stream_idle_timeout_total",
"image_request_duration_seconds",
"image_requests_total",
"make_scrape_registry",
diff --git a/app/core/utils/sse.py b/app/core/utils/sse.py
index ad4e8993ec..b25d185d7f 100644
--- a/app/core/utils/sse.py
+++ b/app/core/utils/sse.py
@@ -3,7 +3,7 @@
import asyncio
import json
import re
-from collections.abc import AsyncIterator, Mapping
+from collections.abc import AsyncIterator, Callable, Mapping
from app.core.errors import ResponseFailedEvent
from app.core.types import JsonValue
@@ -26,6 +26,7 @@ async def inject_sse_keepalives(
interval_seconds: float,
*,
keepalive_frame: str = SSE_KEEPALIVE_FRAME,
+ on_keepalive: Callable[[], None] | None = None,
) -> AsyncIterator[str]:
"""Wrap an SSE event iterator and emit comment heartbeats on idle gaps.
@@ -56,6 +57,8 @@ async def _next_chunk(it: AsyncIterator[str]) -> str:
timeout=interval_seconds,
)
except asyncio.TimeoutError:
+ if on_keepalive is not None:
+ on_keepalive()
yield keepalive_frame
continue
except StopAsyncIteration:
diff --git a/app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py b/app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py
new file mode 100644
index 0000000000..b246db6525
--- /dev/null
+++ b/app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py
@@ -0,0 +1,47 @@
+"""add durable HTTP bridge retry circuit state
+
+Revision ID: 20260717_000000_add_http_bridge_retry_circuits
+Revises: 20260717_000000_optimize_dashboard_hot_path_indexes
+Create Date: 2026-07-17
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "20260717_000000_add_http_bridge_retry_circuits"
+down_revision = "20260717_000000_optimize_dashboard_hot_path_indexes"
+branch_labels = None
+depends_on = None
+
+_TABLE_NAME = "http_bridge_retry_circuits"
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ if inspector.has_table(_TABLE_NAME):
+ return
+ op.create_table(
+ _TABLE_NAME,
+ sa.Column("session_key_kind", sa.String(length=64), nullable=False),
+ sa.Column("session_key_hash", sa.String(length=64), nullable=False),
+ sa.Column("api_key_scope", sa.String(length=255), nullable=False),
+ sa.Column("consecutive_failures", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("cooldown_until_epoch", sa.Float(), nullable=False, server_default="0"),
+ sa.Column("last_detail", sa.String(length=255), nullable=True),
+ sa.Column("updated_at_epoch", sa.Float(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "session_key_kind",
+ "session_key_hash",
+ "api_key_scope",
+ name="pk_http_bridge_retry_circuits",
+ ),
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ if sa.inspect(bind).has_table(_TABLE_NAME):
+ op.drop_table(_TABLE_NAME)
diff --git a/app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py b/app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py
new file mode 100644
index 0000000000..367cbb933f
--- /dev/null
+++ b/app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py
@@ -0,0 +1,22 @@
+"""merge retry circuits and dashboard index heads
+
+Revision ID: 20260717_000001_merge_retry_circuits_and_dashboard_indexes
+Revises:
+- 20260717_000000_add_http_bridge_retry_circuits
+Create Date: 2026-07-17 00:00:00.000000
+"""
+
+from __future__ import annotations
+
+revision = "20260717_000001_merge_retry_circuits_and_dashboard_indexes"
+down_revision = ("20260717_000000_add_http_bridge_retry_circuits",)
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py b/app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py
new file mode 100644
index 0000000000..963bb7bf58
--- /dev/null
+++ b/app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py
@@ -0,0 +1,33 @@
+"""merge the deployed retry-circuit and request-log schema heads
+
+Revision ID: 20260724_000000_merge_request_log_schema_heads
+Revises:
+- 20260717_000001_merge_retry_circuits_and_dashboard_indexes
+- 20260722_000000_backfill_request_log_useragent_families
+Create Date: 2026-07-24 00:00:00.000000
+
+The deployed SQLite database was previously stamped at the retry-circuit
+merge revision while the request-log conversation-id branch was not applied.
+Keeping the retry-circuit merge and the request-usage rollup as parents lets
+Alembic apply the missing request-log revisions before converging on one head
+without leaving the rollup revision as a second head or rewriting migration
+history.
+"""
+
+from __future__ import annotations
+
+revision = "20260724_000000_merge_request_log_schema_heads"
+down_revision = (
+ "20260717_000001_merge_retry_circuits_and_dashboard_indexes",
+ "20260724_000000_add_request_usage_time_rollups",
+)
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py b/app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py
new file mode 100644
index 0000000000..1ef9c5a100
--- /dev/null
+++ b/app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py
@@ -0,0 +1,37 @@
+"""repair request-usage rollups for databases stamped at the old merge head
+
+Revision ID: 20260726_000000_repair_request_usage_rollups_after_merge
+Revises: 20260724_000000_merge_request_log_schema_heads
+Create Date: 2026-07-26
+
+Some deployed databases were stamped at the request-log merge revision before
+the request-usage rollup child was connected to that merge. Changing the
+parent tuple cannot make Alembic replay an already-applied revision, so those
+databases need a forward-only repair step. The canonical rollup migration is
+idempotent and safely creates any missing tables or watermark column here.
+"""
+
+from __future__ import annotations
+
+import importlib
+from types import ModuleType
+
+revision = "20260726_000000_repair_request_usage_rollups_after_merge"
+down_revision = "20260724_000000_merge_request_log_schema_heads"
+branch_labels = None
+depends_on = None
+
+
+def _rollup_migration() -> ModuleType:
+ return importlib.import_module("app.db.alembic.versions.20260724_000000_add_request_usage_time_rollups")
+
+
+def upgrade() -> None:
+ _rollup_migration().upgrade()
+
+
+def downgrade() -> None:
+ # This revision repairs databases that were already stamped at the merge
+ # head. It must never remove objects owned by the canonical rollup
+ # revision, which remains an ancestor of that merge head on fresh installs.
+ pass
diff --git a/app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py b/app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py
new file mode 100644
index 0000000000..f9c39f86a6
--- /dev/null
+++ b/app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py
@@ -0,0 +1,31 @@
+"""merge pending-tool-call and stamped-rollup-repair heads
+
+Revision ID: 20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads
+Revises:
+- 20260725_000000_add_http_bridge_pending_tool_calls
+- 20260726_000000_repair_request_usage_rollups_after_merge
+Create Date: 2026-07-28
+
+The pending-tool-call migration was added on a branch from the canonical
+request-usage rollup revision, while the forward-only repair migration starts
+from the deployed request-log merge. Keep both paths intact and converge them
+before startup asks Alembic for ``head``.
+"""
+
+from __future__ import annotations
+
+revision = "20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads"
+down_revision = (
+ "20260725_000000_add_http_bridge_pending_tool_calls",
+ "20260726_000000_repair_request_usage_rollups_after_merge",
+)
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py b/app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py
new file mode 100644
index 0000000000..fe2ad670a0
--- /dev/null
+++ b/app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py
@@ -0,0 +1,60 @@
+"""add durable HTTP bridge recovery attempts
+
+Revision ID: 20260730_000000_add_http_bridge_recovery_attempts
+Revises: 20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads
+Create Date: 2026-07-30
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.engine import Connection
+
+revision = "20260730_000000_add_http_bridge_recovery_attempts"
+down_revision = "20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads"
+branch_labels = None
+depends_on = None
+
+_TABLE = "http_bridge_recovery_attempts"
+_STATE = sa.Enum("unknown", "replayed", name="http_bridge_recovery_attempt_state")
+
+
+def _has_table(connection: Connection) -> bool:
+ return sa.inspect(connection).has_table(_TABLE)
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ if _has_table(bind):
+ return
+ op.create_table(
+ _TABLE,
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("session_id", sa.String(36), nullable=False),
+ sa.Column("request_fingerprint", sa.String(64), nullable=False),
+ sa.Column("request_id", sa.String(255), nullable=False),
+ sa.Column("account_id", sa.String(), nullable=True),
+ sa.Column("model", sa.String(), nullable=True),
+ sa.Column("replay_safe", sa.Boolean(), nullable=False, server_default=sa.text("false")),
+ sa.Column("state", _STATE, nullable=False, server_default="unknown"),
+ sa.Column("response_id", sa.Text(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.ForeignKeyConstraint(["session_id"], ["http_bridge_sessions.id"], ondelete="CASCADE"),
+ sa.UniqueConstraint(
+ "session_id",
+ "request_fingerprint",
+ name="uq_http_bridge_recovery_attempts_session_fingerprint",
+ ),
+ )
+ op.create_index("idx_http_bridge_recovery_attempts_state", _TABLE, ["state", "updated_at"])
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ if not _has_table(bind):
+ return
+ op.drop_index("idx_http_bridge_recovery_attempts_state", table_name=_TABLE)
+ op.drop_table(_TABLE)
+ _STATE.drop(bind, checkfirst=True)
diff --git a/app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py b/app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py
new file mode 100644
index 0000000000..9b5ee83c71
--- /dev/null
+++ b/app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py
@@ -0,0 +1,30 @@
+"""merge HTTP bridge recovery and capability lineage heads
+
+Revision ID: 20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads
+Revises:
+- 20260730_000000_add_http_bridge_recovery_attempts
+- 20260731_000000_add_capability_lineage_markers
+Create Date: 2026-08-03
+
+Both migrations are additive and were introduced from independent branches.
+This no-op merge records their convergence so startup and migration checks see
+one canonical Alembic head.
+"""
+
+from __future__ import annotations
+
+revision = "20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads"
+down_revision = (
+ "20260730_000000_add_http_bridge_recovery_attempts",
+ "20260731_000000_add_capability_lineage_markers",
+)
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/models.py b/app/db/models.py
index 521a47d02b..ae98a281e2 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -1682,6 +1682,11 @@ class HttpBridgeSessionState(str, Enum):
CLOSED = "closed"
+class HttpBridgeRecoveryAttemptState(str, Enum):
+ UNKNOWN = "unknown"
+ REPLAYED = "replayed"
+
+
class HttpBridgeSessionRecord(Base):
__tablename__ = "http_bridge_sessions"
@@ -1751,6 +1756,49 @@ class HttpBridgeSessionRecord(Base):
)
+class HttpBridgeRecoveryAttemptRecord(Base):
+ __tablename__ = "http_bridge_recovery_attempts"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ session_id: Mapped[str] = mapped_column(
+ String(36),
+ ForeignKey("http_bridge_sessions.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
+ request_id: Mapped[str] = mapped_column(String(255), nullable=False)
+ account_id: Mapped[str | None] = mapped_column(String, nullable=True)
+ model: Mapped[str | None] = mapped_column(String, nullable=True)
+ replay_safe: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
+ state: Mapped[HttpBridgeRecoveryAttemptState] = mapped_column(
+ SqlEnum(
+ HttpBridgeRecoveryAttemptState,
+ name="http_bridge_recovery_attempt_state",
+ validate_strings=True,
+ values_callable=_enum_values,
+ ),
+ default=HttpBridgeRecoveryAttemptState.UNKNOWN,
+ server_default=text("'unknown'"),
+ nullable=False,
+ )
+ response_id: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False, default=func.now(), server_default=func.now()
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False, default=func.now(), server_default=func.now(), onupdate=func.now()
+ )
+
+ __table_args__ = (
+ UniqueConstraint(
+ "session_id",
+ "request_fingerprint",
+ name="uq_http_bridge_recovery_attempts_session_fingerprint",
+ ),
+ Index("idx_http_bridge_recovery_attempts_state", "state", "updated_at"),
+ )
+
+
class HttpBridgeSessionAlias(Base):
__tablename__ = "http_bridge_session_aliases"
@@ -1793,6 +1841,28 @@ class HttpBridgeSessionAlias(Base):
)
+class HttpBridgeRetryCircuit(Base):
+ __tablename__ = "http_bridge_retry_circuits"
+
+ session_key_kind: Mapped[str] = mapped_column(String(64), primary_key=True)
+ session_key_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
+ api_key_scope: Mapped[str] = mapped_column(String(255), primary_key=True)
+ consecutive_failures: Mapped[int] = mapped_column(
+ Integer,
+ nullable=False,
+ default=0,
+ server_default=text("0"),
+ )
+ cooldown_until_epoch: Mapped[float] = mapped_column(
+ Float,
+ nullable=False,
+ default=0.0,
+ server_default=text("0"),
+ )
+ last_detail: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ updated_at_epoch: Mapped[float] = mapped_column(Float, nullable=False)
+
+
_PRIMARY_WINDOW_INDEX_EXPR = func.coalesce(UsageHistory.window, literal_column("'primary'"))
Index("idx_usage_recorded_at", UsageHistory.recorded_at)
diff --git a/app/main.py b/app/main.py
index 4b59dbe295..37fcd1632f 100644
--- a/app/main.py
+++ b/app/main.py
@@ -518,7 +518,22 @@ async def _activate_bridge_membership(svc: RingMembershipService, iid: str) -> N
logger.warning("Drain timeout reached, proceeding with shutdown")
proxy_service = getattr(app.state, "proxy_service", None)
- if proxy_service is not None and hasattr(proxy_service, "mark_http_bridge_draining"):
+ recovery_settlements_drained = True
+ # Settle detached recovery journals while their origin leases are
+ # still held; bridge teardown below may release those owner fences.
+ if proxy_service is not None and hasattr(proxy_service, "drain_persistence_tasks"):
+ try:
+ recovery_settlements_drained = await proxy_service.drain_persistence_tasks(
+ timeout_seconds=settings.shutdown_drain_timeout_seconds,
+ task_name_prefixes=("http-bridge-recovery-settlement-",),
+ )
+ except Exception:
+ logger.warning("Failed to pre-drain proxy settlement tasks during shutdown", exc_info=True)
+ if (
+ recovery_settlements_drained
+ and proxy_service is not None
+ and hasattr(proxy_service, "mark_http_bridge_draining")
+ ):
try:
await proxy_service.mark_http_bridge_draining()
except Exception:
diff --git a/app/modules/proxy/_service/http_bridge/activity.py b/app/modules/proxy/_service/http_bridge/activity.py
index 27608f44b9..10cce3d065 100644
--- a/app/modules/proxy/_service/http_bridge/activity.py
+++ b/app/modules/proxy/_service/http_bridge/activity.py
@@ -1,15 +1,82 @@
from __future__ import annotations
+from typing import Any
+
from app.modules.proxy._service.http_bridge.helpers import (
+ _close_http_bridge_session_bounded,
_http_bridge_pending_count_nowait,
+ _http_bridge_pending_state_is_stale,
_http_bridge_request_counts_against_queue,
+ _log_http_bridge_event,
+ _raise_http_bridge_incompatible_admission_handoff,
+ _record_http_bridge_unanchored_handoff_recovery,
http_bridge_activity_snapshot_nowait,
)
from app.modules.proxy._service.http_bridge.protocol import _HTTPBridgeServiceProtocol
-from app.modules.proxy._service.support import _HTTPBridgeSession
+from app.modules.proxy._service.support import _http_bridge_session_supports_service_tier, _HTTPBridgeSession
+from app.modules.proxy.affinity import _extract_model_class
class _HTTPBridgeActivityMixin:
+ _http_bridge_pending_state_is_stale = staticmethod(_http_bridge_pending_state_is_stale)
+
+ def _recover_http_bridge_incompatible_admission_handoff(
+ self: Any,
+ key: Any,
+ existing: Any,
+ force_durable_takeover: bool,
+ original_request_unanchored: bool,
+ request_model: str | None,
+ api_key: Any,
+ incoming_turn_state: str | None,
+ previous_response_id: str | None,
+ preferred_account_id: str | None,
+ require_preferred_account: bool,
+ request_service_tier: str | None,
+ ) -> tuple[Any, bool]:
+ if original_request_unanchored and existing is not None:
+ detached = self._detach_http_bridge_session_locked(key, expected_session=existing)
+ if detached is not None:
+ force_durable_takeover = True
+ _record_http_bridge_unanchored_handoff_recovery(reason="closed_admission_handoff")
+ _log_http_bridge_event(
+ "unanchored_handoff_recovery",
+ key,
+ account_id=detached.account.id,
+ model=request_model,
+ detail="outcome=retired_closed_admission_handoff",
+ cache_key_family=key.affinity_kind,
+ model_class=_extract_model_class(request_model) if request_model else None,
+ owner_check_applied=False,
+ )
+ self._schedule_http_bridge_session_closes([detached], reason="unanchored_handoff_recovery")
+ return None, force_durable_takeover
+
+ _raise_http_bridge_incompatible_admission_handoff(
+ session=existing,
+ key=key,
+ api_key=api_key,
+ incoming_turn_state=incoming_turn_state,
+ previous_response_id=previous_response_id,
+ preferred_account_id=preferred_account_id,
+ require_preferred_account=require_preferred_account,
+ request_service_tier=request_service_tier,
+ service_tier_supported=_http_bridge_session_supports_service_tier(
+ existing,
+ request_model=request_model,
+ request_service_tier=request_service_tier,
+ ),
+ )
+ raise AssertionError("incompatible admission handoff must raise")
+
+ async def _close_http_bridge_session_bounded(
+ self: Any,
+ session: _HTTPBridgeSession,
+ *,
+ reason: str,
+ ) -> None:
+ await _close_http_bridge_session_bounded(self, session, reason=reason)
+
async def _http_bridge_pending_count(
self: _HTTPBridgeServiceProtocol,
session: _HTTPBridgeSession,
diff --git a/app/modules/proxy/_service/http_bridge/helpers.py b/app/modules/proxy/_service/http_bridge/helpers.py
index ca98a7fa9f..59eca389e0 100644
--- a/app/modules/proxy/_service/http_bridge/helpers.py
+++ b/app/modules/proxy/_service/http_bridge/helpers.py
@@ -48,8 +48,10 @@
PROMETHEUS_AVAILABLE,
bridge_drain_recovery_allowed_total,
bridge_first_turn_timeout_total,
+ bridge_handoff_compatibility_rejection_total,
bridge_instance_mismatch_total,
bridge_reattach_total,
+ bridge_unanchored_handoff_recovery_total,
http_bridge_prewarm_total,
http_bridge_stuck_retire_total,
)
@@ -120,6 +122,7 @@
)
from app.modules.proxy._service.support import (
_HARD_HTTP_BRIDGE_AFFINITY_KINDS, # noqa: F401
+ _REQUEST_TRANSPORT_HTTP,
_WEBSOCKET_FULL_REPLAY_WAIT_POLL_SECONDS, # noqa: F401
_http_bridge_session_supports_service_tier,
_HTTPBridgeSession,
@@ -187,8 +190,15 @@
)
logger = logging.getLogger("app.modules.proxy.service")
+_TASK_CANCEL_TIMEOUT_SECONDS = 1.0
+_TaskResultT = TypeVar("_TaskResultT")
+_HTTP_BRIDGE_PENDING_COUNT_WARNING_INTERVAL_SECONDS = 60.0
+_http_bridge_pending_count_warning_last_logged: dict[tuple[str, str, str], float] = {}
_HTTP_BRIDGE_BACKGROUND_CLOSE_TIMEOUT_SECONDS = 5.0
-_HTTP_BRIDGE_EVENTLESS_RESPONSE_CREATED_MAX_SECONDS = 240.0
+# A healthy upstream acknowledges response.create promptly. Keep the
+# Keep the owner-side watchdog within the client-safe contract while honoring
+# the configured stuck-gate threshold when it is shorter.
+_HTTP_BRIDGE_EVENTLESS_RESPONSE_CREATED_MAX_SECONDS = 60.0
_HTTP_BRIDGE_MISSING_RESPONSE_CREATED_TIMEOUT_DETAIL = "missing_response_created_timeout"
T = TypeVar("T")
@@ -306,14 +316,28 @@ def _http_bridge_pending_count_nowait(
except Exception as exc:
if type(exc).__name__ not in {"WouldBlock", "RuntimeError"}:
raise
- logger.warning(
- "http_bridge_pending_count_unavailable context=%s bridge_kind=%s bridge_key=%s account_id=%s model=%s",
+ warning_key = (
context,
session.key.affinity_kind,
_hash_identifier(session.key.affinity_key),
- session.account.id,
- session.request_model,
)
+ now = time.monotonic()
+ last_logged = _http_bridge_pending_count_warning_last_logged.get(warning_key)
+ if last_logged is None or now - last_logged >= _HTTP_BRIDGE_PENDING_COUNT_WARNING_INTERVAL_SECONDS:
+ _http_bridge_pending_count_warning_last_logged[warning_key] = now
+ if len(_http_bridge_pending_count_warning_last_logged) > 2048:
+ cutoff = now - _HTTP_BRIDGE_PENDING_COUNT_WARNING_INTERVAL_SECONDS * 2
+ for key, timestamp in tuple(_http_bridge_pending_count_warning_last_logged.items()):
+ if timestamp < cutoff:
+ _http_bridge_pending_count_warning_last_logged.pop(key, None)
+ logger.warning(
+ "http_bridge_pending_count_unavailable context=%s bridge_kind=%s bridge_key=%s account_id=%s model=%s",
+ context,
+ session.key.affinity_kind,
+ warning_key[2],
+ session.account.id,
+ session.request_model,
+ )
return None
try:
request_counts_against_queue = _service_global("_http_bridge_request_counts_against_queue")
@@ -390,6 +414,14 @@ def _cleanup_http_bridge_inflight_sessions_nowait(service: Any) -> dict[str, int
}
+def _http_bridge_inflight_creation_count(service: Any) -> int:
+ return sum(
+ 1
+ for future in service._http_bridge_inflight_sessions.values()
+ if not getattr(future, "_http_bridge_handoff", False)
+ )
+
+
def http_bridge_activity_snapshot_nowait(service: Any) -> dict[str, int | bool]:
inflight_cleanup = _cleanup_http_bridge_inflight_sessions_nowait(service)
live_sessions = 0
@@ -634,6 +666,51 @@ def _normalize_http_bridge_error_event(
return normalized_event_block, normalized_payload, parsed_event, "response.failed"
+def _http_bridge_pending_state_is_stale(
+ request_state: _WebSocketRequestState,
+ *,
+ now: float,
+ threshold_seconds: float,
+ session_closed: bool = False,
+) -> bool:
+ """Identify pre-created bridge requests that never received upstream activity."""
+ if request_state.transport != _REQUEST_TRANSPORT_HTTP or request_state.skip_request_log:
+ return False
+ # Previous-response and turn-state identifiers preserve hard continuity
+ # while the socket is live. Once it closes, that continuity is already lost.
+ # A silent-but-open upstream must not preserve continuity forever: with
+ # zero upstream activity the anchored request wedges the create gate and
+ # every follow-up on the session, so cap the anchored wait at 2x the
+ # stuck-gate threshold before allowing retirement.
+ if not session_closed and (request_state.previous_response_id is not None or request_state.hard_continuity_anchor):
+ anchored_wait_started_at = (
+ request_state.response_create_gate_wait_started_at
+ if request_state.response_create_gate_wait_started_at is not None
+ else request_state.started_at
+ )
+ if max(0.0, now - anchored_wait_started_at) < threshold_seconds * 2.0:
+ return False
+ # Do not require the gate/awaiting flags: retry and reader re-enqueue
+ # paths re-register states whose admission flags were already cleared,
+ # and leaning on them starves the watchdog entirely (observed prod
+ # wedges 2026-07-20). A reattached stream can deliver events whose
+ # response.created was lost, so use the most recent upstream event as
+ # the silence clock instead of aging every active stream from its
+ # original gate wait.
+ if request_state.response_id is not None:
+ return False
+ if request_state.latency_response_created_ms is not None:
+ return False
+ wait_started_at = (
+ request_state.response_create_gate_wait_started_at
+ if request_state.response_create_gate_wait_started_at is not None
+ else request_state.started_at
+ )
+ if request_state.response_event_count > 0 and request_state.last_upstream_activity_at is not None:
+ wait_started_at = request_state.last_upstream_activity_at
+ return max(0.0, now - wait_started_at) >= threshold_seconds
+
+
def _http_bridge_request_counts_against_queue(request_state: _WebSocketRequestState) -> bool:
return not request_state.draining_until_terminal
@@ -658,6 +735,10 @@ def _http_bridge_eventless_precreated_deadline(
or request_state.last_downstream_sequence_number is not None
):
return None
+ # Non-response telemetry (for example ``codex.rate_limits``) may update
+ # the generic activity marker, but it must not extend the response.create
+ # acknowledgement deadline. The eventless watchdog is intentionally
+ # anchored to the send time until a response-lifecycle event is observed.
return sent_at + min(
float(stuck_gate_retire_after_seconds),
_HTTP_BRIDGE_EVENTLESS_RESPONSE_CREATED_MAX_SECONDS,
@@ -784,7 +865,11 @@ def _http_bridge_compatible(
def _http_bridge_alias_target_is_stale(session: _HTTPBridgeSession | None) -> bool:
- return session is None or session.closed or not _http_bridge_session_account_active(session)
+ return (
+ session is None
+ or (session.closed and not session.handoff_in_progress)
+ or not _http_bridge_session_account_active(session)
+ )
def _http_bridge_incompatible_model_fork_key(
@@ -1176,7 +1261,86 @@ def _require_http_bridge_bound_account_not_excluded(
)
-def _raise_http_bridge_incompatible_admission_handoff() -> None:
+def _record_http_bridge_handoff_compatibility_rejection(
+ *,
+ session: "_HTTPBridgeSession",
+ key: "_HTTPBridgeSessionKey",
+ api_key: ApiKeyData | None,
+ incoming_turn_state: str | None,
+ previous_response_id: str | None,
+ preferred_account_id: str | None,
+ require_preferred_account: bool,
+ request_service_tier: str | None,
+ service_tier_supported: bool,
+) -> None:
+ continuity_anchor = (
+ "previous_response_id"
+ if previous_response_id is not None
+ else "turn_state"
+ if incoming_turn_state is not None
+ else "none"
+ )
+ preferred_account = (
+ "required" if require_preferred_account else "preferred" if preferred_account_id is not None else "none"
+ )
+ service_tier = (
+ "not_requested" if request_service_tier is None else "supported" if service_tier_supported else "unsupported"
+ )
+ api_key_scope = "scoped" if api_key is not None and api_key.id is not None else "unscoped"
+ if PROMETHEUS_AVAILABLE and bridge_handoff_compatibility_rejection_total is not None:
+ bridge_handoff_compatibility_rejection_total.labels(
+ continuity_anchor=continuity_anchor,
+ preferred_account=preferred_account,
+ service_tier=service_tier,
+ api_key_scope=api_key_scope,
+ ).inc()
+ logger.warning(
+ "http_bridge_handoff_compatibility_rejected bridge_kind=%s bridge_key=%s key_strength=%s "
+ "continuity_anchor=%s preferred_account=%s preferred_account_id=%s require_preferred=%s "
+ "service_tier=%s api_key_scope=%s existing_closed=%s admission_waiters=%s pending_count=%s",
+ key.affinity_kind,
+ _hash_identifier(key.affinity_key),
+ _http_bridge_key_strength(key),
+ continuity_anchor,
+ preferred_account,
+ _hash_identifier_or_none(preferred_account_id),
+ require_preferred_account,
+ service_tier,
+ api_key_scope,
+ session.closed,
+ getattr(session, "admission_waiter_count", 0),
+ len(session.pending_requests),
+ )
+
+
+def _record_http_bridge_unanchored_handoff_recovery(*, reason: str) -> None:
+ if PROMETHEUS_AVAILABLE and bridge_unanchored_handoff_recovery_total is not None:
+ bridge_unanchored_handoff_recovery_total.labels(reason=reason).inc()
+
+
+def _raise_http_bridge_incompatible_admission_handoff(
+ *,
+ session: "_HTTPBridgeSession",
+ key: "_HTTPBridgeSessionKey",
+ api_key: ApiKeyData | None,
+ incoming_turn_state: str | None,
+ previous_response_id: str | None,
+ preferred_account_id: str | None,
+ require_preferred_account: bool,
+ request_service_tier: str | None,
+ service_tier_supported: bool,
+) -> None:
+ _record_http_bridge_handoff_compatibility_rejection(
+ session=session,
+ key=key,
+ api_key=api_key,
+ incoming_turn_state=incoming_turn_state,
+ previous_response_id=previous_response_id,
+ preferred_account_id=preferred_account_id,
+ require_preferred_account=require_preferred_account,
+ request_service_tier=request_service_tier,
+ service_tier_supported=service_tier_supported,
+ )
raise ProxyResponseError(
503,
openai_error(
@@ -1456,6 +1620,101 @@ def _http_bridge_durable_lease_ttl_seconds() -> float:
return float(RING_STALE_THRESHOLD_SECONDS)
+def _http_bridge_durable_release_allowed(service: Any, session: Any) -> bool:
+ session_id = getattr(session, "durable_session_id", None)
+ owner_epoch = getattr(session, "durable_owner_epoch", None)
+ if session_id is None or owner_epoch is None:
+ return False
+ return not any(
+ not task.done() and getattr(task, "_http_bridge_recovery_session_id", None) == session_id
+ for task in service._background_cleanup_tasks
+ )
+
+
+async def _drain_cancelled_task(task: asyncio.Task[Any]) -> None:
+ await asyncio.gather(task, return_exceptions=True)
+
+
+def _cancel_and_track_cancelled_task(
+ task: asyncio.Task[Any],
+ *,
+ label: str,
+ cleanup_tasks: set[asyncio.Task[None]] | None,
+ cancel_task: bool = True,
+) -> None:
+ if cancel_task:
+ task.cancel()
+ cleanup_task = asyncio.create_task(_drain_cancelled_task(task), name=f"cancelled-task-cleanup-{label}")
+ if cleanup_tasks is not None:
+ cleanup_tasks.add(cleanup_task)
+ cleanup_task.add_done_callback(cleanup_tasks.discard)
+
+
+async def _await_cancelled_task(
+ task: asyncio.Task[_TaskResultT],
+ *,
+ timeout_seconds: float = _TASK_CANCEL_TIMEOUT_SECONDS,
+ label: str,
+ cleanup_tasks: set[asyncio.Task[None]] | None = None,
+) -> bool:
+ caller_task = asyncio.current_task()
+ # Give a new child one scheduling turn before cancellation so
+ # cancellation-resistant tasks enter the deferred-drain path.
+ if not task.done():
+ try:
+ await asyncio.sleep(0)
+ except asyncio.CancelledError:
+ _cancel_and_track_cancelled_task(task, label=label, cleanup_tasks=cleanup_tasks)
+ raise
+ task.cancel()
+ try:
+ await asyncio.wait_for(asyncio.shield(task), timeout=timeout_seconds)
+ except asyncio.CancelledError:
+ if caller_task is not None and caller_task.cancelling():
+ _cancel_and_track_cancelled_task(task, label=label, cleanup_tasks=cleanup_tasks, cancel_task=False)
+ raise
+ return True
+ except TimeoutError:
+ logger.warning("Timed out waiting for %s cancellation", label)
+ _cancel_and_track_cancelled_task(task, label=label, cleanup_tasks=cleanup_tasks, cancel_task=False)
+ return False
+ return True
+
+
+async def _persist_http_bridge_replacement_account(
+ service: _HTTPBridgeServiceProtocol,
+ session: _HTTPBridgeSession,
+ account_id: str,
+) -> None:
+ if account_id == session.account.id or session.durable_session_id is None or session.durable_owner_epoch is None:
+ return
+ try:
+ rebound = await service._durable_bridge.rebind_session_account(
+ session_id=session.durable_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=session.durable_owner_epoch,
+ account_id=account_id,
+ clear_continuity=True,
+ )
+ except Exception as exc:
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses session account could not be persisted; retry the request.",
+ ),
+ ) from exc
+ if not rebound:
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses session ownership changed during account recovery; retry the request.",
+ ),
+ )
+
+
async def _release_http_bridge_unanchored_handoffs_for_request(
service: _HTTPBridgeServiceProtocol,
*,
@@ -2005,6 +2264,11 @@ def _http_bridge_owner_lookup_unavailable_error_envelope() -> OpenAIErrorEnvelop
)
+def _mark_http_bridge_reader_handoff_reconnect_failed(session: Any, old_reader: Any) -> None:
+ if old_reader is not None:
+ session.closed = True
+
+
def _http_bridge_previous_response_owner_unavailable_error() -> ProxyResponseError:
return ProxyResponseError(
502,
@@ -2243,6 +2507,10 @@ def _log_http_bridge_event(
cache_key_family: str | None = None,
model_class: str | None = None,
owner_check_applied: bool | None = None,
+ error_message: str | None = None,
+ upstream_close_code: int | None = None,
+ response_events_seen: int | None = None,
+ transport_classification: str | None = None,
) -> None:
level = logging.INFO
if event in {
@@ -2251,22 +2519,25 @@ def _log_http_bridge_event(
"send_failure",
"retry_fresh_upstream",
"retry_precreated",
+ "retry_precreated_clean_close",
"reconnect",
"terminal_error",
"capacity_exhausted_active_sessions",
"owner_mismatch",
"owner_forward_fail",
+ "missing_response_created_timeout",
"prompt_cache_locality_miss",
"reallocation_orphan",
"context_overflow_rollover",
- "missing_response_created_timeout",
+ "reader_failure",
}:
level = logging.WARNING
logger.log(
level,
"http_bridge_event event=%s bridge_kind=%s bridge_key=%s account_id=%s"
" model=%s pending=%s detail=%s cache_key_family=%s model_class=%s"
- " key_strength=%s owner_check_applied=%s",
+ " key_strength=%s owner_check_applied=%s error_message=%s upstream_close_code=%s"
+ " response_events_seen=%s transport_classification=%s",
event,
key.affinity_kind,
_hash_identifier(key.affinity_key),
@@ -2278,6 +2549,10 @@ def _log_http_bridge_event(
model_class,
_http_bridge_key_strength(key),
owner_check_applied,
+ error_message,
+ upstream_close_code,
+ response_events_seen,
+ transport_classification,
)
@@ -2329,6 +2604,7 @@ def _wrapper(*args: Any, **kwargs: Any) -> Any:
"_record_bridge_drain_recovery_allowed",
"_is_missing_durable_bridge_table_error",
"_http_bridge_durable_lease_ttl_seconds",
+ "_persist_http_bridge_replacement_account",
"_forwarded_http_bridge_session_key",
"_http_bridge_requires_cluster_registration",
"_effective_http_bridge_idle_ttl_seconds",
diff --git a/app/modules/proxy/_service/http_bridge/mixin.py b/app/modules/proxy/_service/http_bridge/mixin.py
index d8dfeb0c51..17e1877620 100644
--- a/app/modules/proxy/_service/http_bridge/mixin.py
+++ b/app/modules/proxy/_service/http_bridge/mixin.py
@@ -69,8 +69,6 @@
_HTTP_BRIDGE_BACKGROUND_CLOSE_TIMEOUT_SECONDS,
_HTTP_BRIDGE_INFLIGHT_STARTED_AT_ATTR,
_active_http_bridge_instance_ring,
- _await_task_deferring_cancellation,
- _close_http_bridge_session_bounded,
_durable_bridge_lookup_active_owner,
_durable_bridge_lookup_allows_local_reuse,
_forwarded_http_bridge_session_key,
@@ -82,10 +80,12 @@
_http_bridge_can_single_instance_prompt_cache_takeover_without_anchor,
_http_bridge_compatible,
_http_bridge_continuity_lost_error_envelope,
+ _http_bridge_durable_release_allowed,
_http_bridge_endpoint_matches_current_instance,
_http_bridge_eviction_priority,
_http_bridge_has_durable_recovery_anchor,
_http_bridge_incompatible_model_fork_key,
+ _http_bridge_inflight_creation_count,
_http_bridge_key_strength,
_http_bridge_locally_owned_fork_key,
_http_bridge_models_compatible,
@@ -109,8 +109,9 @@
_http_bridge_turn_state_alias_key,
_log_http_bridge_event,
_log_http_bridge_startup_wait_timeout,
+ _mark_http_bridge_reader_handoff_reconnect_failed,
+ _persist_http_bridge_replacement_account,
_preferred_http_bridge_reconnect_turn_state,
- _raise_http_bridge_incompatible_admission_handoff,
_record_bridge_drain_recovery_allowed,
_record_bridge_first_turn_timeout,
_refresh_reused_http_bridge_session_with_handoff,
@@ -143,26 +144,13 @@
from app.modules.proxy._service.http_bridge.session_registry import _HTTPBridgeSessionRegistryMixin
from app.modules.proxy._service.http_bridge.streaming import _HTTPBridgeStreamingMixin
from app.modules.proxy._service.http_bridge.upstream_events import _HTTPBridgeUpstreamEventsMixin
-from app.modules.proxy._service.observability import (
- _hash_identifier as _hash_identifier,
-)
-from app.modules.proxy._service.observability import (
- _hash_identifier_or_none as _hash_identifier_or_none,
-)
-from app.modules.proxy._service.observability import (
- _interesting_header_keys as _interesting_header_keys,
-)
-from app.modules.proxy._service.observability import (
- _tools_hash as _tools_hash,
-)
-from app.modules.proxy._service.observability import (
- _truncate_identifier as _truncate_identifier,
-)
+from app.modules.proxy._service.observability import _hash_identifier
from app.modules.proxy._service.support import (
_ACCOUNT_MODEL_UNSUPPORTED_ERROR_CODE,
_HARD_HTTP_BRIDGE_AFFINITY_KINDS, # noqa: F401
_WEBSOCKET_FULL_REPLAY_WAIT_POLL_SECONDS, # noqa: F401
_clear_websocket_precreated_replay_fallback,
+ _complete_http_bridge_handoff,
_copy_websocket_route_metadata_to_session,
_HTTPBridgeOwnerForward,
_HTTPBridgeSession,
@@ -237,14 +225,6 @@ class _HTTPBridgeMixin(
_HTTPBridgeUpstreamEventsMixin,
_HTTPBridgeServiceProtocol,
):
- async def _close_http_bridge_session_bounded(
- self,
- session: "_HTTPBridgeSession",
- *,
- reason: str,
- ) -> None:
- await _close_http_bridge_session_bounded(self, session, reason=reason)
-
def _schedule_http_bridge_session_closes(
self,
sessions: list["_HTTPBridgeSession"],
@@ -273,6 +253,7 @@ async def _drain_http_bridge_background_cleanup_tasks(self, *, reason: str) -> N
and (
task.get_name().startswith("proxy-http_bridge_session_close-")
or task.get_name().startswith("http-bridge-close-")
+ or task.get_name().startswith("cancelled-task-cleanup-")
)
]
if not tasks:
@@ -302,6 +283,8 @@ async def _fail_http_bridge_inflight_session_creation(
current_future = self._http_bridge_inflight_sessions.get(key)
if current_future is not inflight_future:
return False
+ if getattr(inflight_future, "_http_bridge_handoff", False):
+ return False
self._http_bridge_inflight_sessions.pop(key, None)
if inflight_future.done():
return True
@@ -325,6 +308,8 @@ async def _evict_http_bridge_inflight_waiter(
break
if stale_key is None:
return None
+ if getattr(inflight_future, "_http_bridge_handoff", False):
+ return None
self._http_bridge_inflight_sessions.pop(stale_key, None)
if not inflight_future.done():
inflight_future.set_exception(exc)
@@ -362,7 +347,6 @@ async def _get_or_create_http_bridge_session(
session_header_fallback_key: "_HTTPBridgeSessionKey | None" = None,
exclude_account_ids: Collection[str] | None = None,
) -> "_HTTPBridgeSession": ...
-
@overload
async def _get_or_create_http_bridge_session(
self,
@@ -394,7 +378,6 @@ async def _get_or_create_http_bridge_session(
session_header_fallback_key: "_HTTPBridgeSessionKey | None" = None,
exclude_account_ids: Collection[str] | None = None,
) -> "_HTTPBridgeSession | _HTTPBridgeOwnerForward": ...
-
async def _get_or_create_http_bridge_session(
self,
key: "_HTTPBridgeSessionKey",
@@ -605,7 +588,7 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
previous_session = self._http_bridge_sessions.get(previous_key)
if (
previous_session is not None
- and not previous_session.closed
+ and (not previous_session.closed or previous_session.handoff_in_progress)
and _http_bridge_session_account_active(previous_session)
and _http_bridge_compatible(previous_session, request_model, request_service_tier, True)
and _http_bridge_session_matches_preferred_account(
@@ -649,7 +632,10 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
bind_account_neutral_recovery_owner(previous_session)
key = recovery_fork_key
continue
- elif not _http_bridge_alias_target_is_stale(previous_session):
+ elif previous_session is not None and (
+ not _http_bridge_alias_target_is_stale(previous_session)
+ and not previous_session.handoff_in_progress
+ ):
raise ProxyResponseError(502, _http_bridge_continuity_lost_error_envelope())
elif previous_key is not None:
self._http_bridge_previous_response_index.pop(previous_alias_key, None)
@@ -690,7 +676,8 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
fork_key = _http_bridge_parallel_fork_key(
key=key,
session=existing,
- inflight_creation=key in self._http_bridge_inflight_sessions,
+ inflight_creation=key in self._http_bridge_inflight_sessions
+ and not bool(existing and existing.handoff_in_progress),
incoming_turn_state=incoming_turn_state,
previous_response_id=previous_response_id,
request_model=request_model,
@@ -710,7 +697,19 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
)
continue
if retained_handoff and not reusable:
- _raise_http_bridge_incompatible_admission_handoff()
+ existing, force_durable_takeover = self._recover_http_bridge_incompatible_admission_handoff(
+ key,
+ existing,
+ force_durable_takeover,
+ original_request_unanchored,
+ request_model,
+ api_key,
+ incoming_turn_state,
+ previous_response_id,
+ preferred_account_id,
+ require_preferred_account,
+ request_service_tier,
+ )
if reusable:
assert existing is not None
current_instance = settings.http_responses_session_bridge_instance_id
@@ -1093,7 +1092,7 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
bridge_soft_local_rebind_total.inc()
if bridge_local_rebind_total is not None:
bridge_local_rebind_total.labels(reason="prompt_cache_locality_miss").inc()
- if existing is not None:
+ if existing is not None and not existing.handoff_in_progress:
old_account_id = existing.account.id
_log_http_bridge_event(
"discard_stale",
@@ -1250,7 +1249,7 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
)
elif inflight_future is None:
while (
- len(self._http_bridge_sessions) + len(self._http_bridge_inflight_sessions) >= max_sessions
+ len(self._http_bridge_sessions) + _http_bridge_inflight_creation_count(self) >= max_sessions
and self._http_bridge_sessions
):
evictable_sessions: list[tuple[_HTTPBridgeSessionKey, _HTTPBridgeSession]] = []
@@ -1287,9 +1286,13 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
detached = self._detach_http_bridge_session_locked(lru_key, expected_session=lru_session)
if detached is not None:
sessions_to_close_before_create.append(detached)
- if len(self._http_bridge_sessions) + len(self._http_bridge_inflight_sessions) >= max_sessions:
- if self._http_bridge_inflight_sessions:
- capacity_wait_future = next(iter(self._http_bridge_inflight_sessions.values()))
+ if len(self._http_bridge_sessions) + _http_bridge_inflight_creation_count(self) >= max_sessions:
+ if _http_bridge_inflight_creation_count(self):
+ capacity_wait_future = next(
+ future
+ for future in self._http_bridge_inflight_sessions.values()
+ if not getattr(future, "_http_bridge_handoff", False)
+ )
else:
_log_http_bridge_event(
"capacity_exhausted_active_sessions",
@@ -1297,7 +1300,7 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
account_id=None,
model=request_model,
pending_count=(
- len(self._http_bridge_sessions) + len(self._http_bridge_inflight_sessions)
+ len(self._http_bridge_sessions) + _http_bridge_inflight_creation_count(self)
),
cache_key_family=key.affinity_kind,
model_class=_extract_model_class(request_model) if request_model else None,
@@ -1364,7 +1367,6 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
except Exception:
pass
continue
-
if inflight_future is not None and not owns_creation:
wait_timeout_seconds = _proxy_admission_wait_timeout_seconds(settings)
try:
@@ -1454,7 +1456,6 @@ def bind_account_neutral_recovery_owner(session: _HTTPBridgeSession) -> None:
if detached is not None and not retiring_with_visible_requests:
self._schedule_http_bridge_session_closes([detached], reason="registry_detach")
continue
-
created_session: _HTTPBridgeSession | None = None
session_registered = False
try:
@@ -1606,11 +1607,11 @@ def _prune_http_bridge_sessions_locked(self) -> list["_HTTPBridgeSession"]:
for key, session in self._http_bridge_sessions.items():
if _http_bridge_session_has_admission_waiter(session):
continue
+ if session.handoff_in_progress:
+ continue
if session.closed:
stale_keys.append(key)
continue
- # The request owns this idle session until submit makes activity visible;
- # pruning it during admission or durable refresh would invalidate the handoff.
if getattr(session, "unanchored_reservation_id", None) is not None:
continue
pending_count = self._http_bridge_pending_count_nowait(session, context="idle_prune")
@@ -1641,6 +1642,7 @@ async def _close_http_bridge_session(
session: "_HTTPBridgeSession",
*,
turn_state_lock_held: bool = False,
+ release_durable_session: bool = True,
) -> None:
session.closed = True
if turn_state_lock_held:
@@ -1656,7 +1658,7 @@ async def _close_http_bridge_session(
logger.warning("Failed to release HTTP bridge account lease during close", exc_info=True)
finally:
session.account_lease = None
- if session.durable_session_id is not None and session.durable_owner_epoch is not None:
+ if release_durable_session and _http_bridge_durable_release_allowed(self, session):
try:
await self._durable_bridge.release_live_session(
session_id=session.durable_session_id,
@@ -1671,7 +1673,11 @@ async def _close_http_bridge_session(
if upstream_reader is asyncio.current_task():
session.upstream_reader = None
else:
- await _await_cancelled_task(upstream_reader, label="http bridge upstream reader")
+ await _await_cancelled_task(
+ upstream_reader,
+ label="http bridge upstream reader",
+ cleanup_tasks=self._background_cleanup_tasks,
+ )
if session.upstream_reader is upstream_reader:
session.upstream_reader = None
try:
@@ -1996,23 +2002,36 @@ async def _reconnect_http_bridge_session(
owner_rebind_affinity: _AffinityPolicy | None = None,
selection_affinity: _AffinityPolicy | None = None,
) -> None:
- # A replacement reader can start before its caller resends the request.
- # Clear the prior attempt first so an expired send timestamp cannot
- # retire the fresh socket before the next real send re-arms it.
request_state.response_create_sent_at = None
account_neutral_recovery = is_http_bridge_account_neutral_replay(
kind=session.key.affinity_kind,
key=session.key.affinity_key,
)
require_same_account = require_same_account or account_neutral_recovery
- old_account_id = session.account.id
old_upstream = session.upstream
old_reader = session.upstream_reader if restart_reader else None
+ session.handoff_in_progress = True
+ inflight_sessions = self._http_bridge_inflight_sessions
+ handoff_future = inflight_sessions.get(session.key) or asyncio.get_running_loop().create_future()
+ inflight_sessions.setdefault(session.key, handoff_future)
+ setattr(handoff_future, "_http_bridge_handoff", True)
+ session.handoff_future = handoff_future
+ session.closed = True
if old_reader is not None:
if old_reader is not asyncio.current_task():
- cancelled = await _await_cancelled_task(old_reader, label="http bridge upstream reader")
+ try:
+ cancelled = await _await_cancelled_task(
+ old_reader,
+ label="http bridge upstream reader",
+ cleanup_tasks=self._background_cleanup_tasks,
+ )
+ except BaseException:
+ session.closed = True
+ _complete_http_bridge_handoff(session, self._http_bridge_inflight_sessions)
+ raise
if not cancelled:
session.closed = True
+ _complete_http_bridge_handoff(session, self._http_bridge_inflight_sessions)
raise ProxyResponseError(
502,
openai_error(
@@ -2024,30 +2043,34 @@ async def _reconnect_http_bridge_session(
request_state,
_http_bridge_request_budget_seconds(_service_get_settings()),
)
- settings = await _service_get_settings_cache().get()
- session.api_key = request_state.api_key
- forced_refresh_account_id = request_state.force_refresh_account_id
- excluded_account_ids: set[str] = set(request_state.excluded_account_ids)
- requested_preferred_account_id = (
- request_state.preferred_account_id if require_preferred_account or account_neutral_recovery else None
- )
- required_preferred_account_id = resolve_required_account_id(
- ("requested reconnect owner", requested_preferred_account_id),
- (
- "account-neutral recovery",
- session.account.id if account_neutral_recovery else None,
- ),
- )
- close_skips_account = session.last_upstream_close_code in _UPSTREAM_CLOSE_CODES_SKIP_SAME_ACCOUNT_RETRY
- hard_close_account_bound = session.key.strength == "hard" and (close_skips_account or require_same_account)
- skip_same_account = (
- session.key.strength != "hard" and close_skips_account and required_preferred_account_id is None
- )
- if required_preferred_account_id is not None and required_preferred_account_id in excluded_account_ids:
- raise _http_bridge_previous_response_owner_unavailable_error()
- _require_http_bridge_bound_account_not_excluded(
- hard_close_account_bound, session.account.id, excluded_account_ids
- )
+ try:
+ settings = await _service_get_settings_cache().get()
+ session.api_key = request_state.api_key
+ forced_refresh_account_id = request_state.force_refresh_account_id
+ excluded_account_ids: set[str] = set(request_state.excluded_account_ids)
+ requested_preferred_account_id = (
+ request_state.preferred_account_id if require_preferred_account or account_neutral_recovery else None
+ )
+ required_preferred_account_id = resolve_required_account_id(
+ ("requested reconnect owner", requested_preferred_account_id),
+ ("account-neutral recovery", session.account.id if account_neutral_recovery else None),
+ )
+ close_skips_account = session.last_upstream_close_code in _UPSTREAM_CLOSE_CODES_SKIP_SAME_ACCOUNT_RETRY
+ hard_close_account_bound = session.key.strength == "hard" and (close_skips_account or require_same_account)
+ skip_same_account = (
+ session.key.strength != "hard" and close_skips_account and required_preferred_account_id is None
+ )
+ if required_preferred_account_id is not None and required_preferred_account_id in excluded_account_ids:
+ session.closed = True
+ _complete_http_bridge_handoff(session, self._http_bridge_inflight_sessions)
+ raise _http_bridge_previous_response_owner_unavailable_error()
+ _require_http_bridge_bound_account_not_excluded(
+ hard_close_account_bound, session.account.id, excluded_account_ids
+ )
+ except BaseException:
+ session.closed = True
+ _complete_http_bridge_handoff(session, self._http_bridge_inflight_sessions)
+ raise
if skip_same_account:
excluded_account_ids.add(session.account.id)
retry_same_account_once = not skip_same_account and session.account.id not in excluded_account_ids
@@ -2085,49 +2108,89 @@ async def release_selected_account_lease() -> None:
async with session.pending_lock:
if session.account_lease is not None and lease.lease_id == session.account_lease.lease_id:
session.account_lease = None
- await self._load_balancer.release_account_lease(lease)
+ try:
+ await self._load_balancer.release_account_lease(lease)
+ except BaseException:
+ complete_failed_handoff()
+ raise
async def abandon_selected_account_retry(selected_account: Any) -> None:
nonlocal preferred_candidate_id
if hard_close_account_bound or selected_account_model_replacement:
await release_selected_account_lease()
+ complete_failed_handoff()
raise
excluded_account_ids.add(selected_account.id)
preferred_candidate_id = None
await release_selected_account_lease()
+ async def open_replacement_upstream(selected_account: Any, selected_headers: dict[str, str]) -> Any:
+ try:
+ return await self._open_upstream_websocket_with_budget(
+ selected_account,
+ selected_headers,
+ timeout_seconds=_remaining_budget_seconds(deadline),
+ request_state=request_state,
+ )
+ except Exception:
+ session.closed = True
+ raise
+
+ def complete_failed_handoff() -> None:
+ session.closed = True
+ _mark_http_bridge_reader_handoff_reconnect_failed(session, old_reader)
+ _complete_http_bridge_handoff(session, self._http_bridge_inflight_sessions)
+
+ def require_bound_account() -> None:
+ try:
+ _require_http_bridge_bound_account_not_excluded(
+ hard_close_account_bound, session.account.id, excluded_account_ids
+ )
+ except BaseException:
+ complete_failed_handoff()
+ raise
+
while True:
reuse_current_account_lease = preferred_candidate_id == session.account.id and bool(session.account_lease)
- selection = await self._select_account_with_budget_for_stream(
- deadline,
- request_id=request_state.request_log_id or request_state.request_id,
- kind="http_bridge",
- request_stage="reattach",
- api_key=session.api_key,
- affinity_policy=selection_affinity or session.affinity,
- prefer_earlier_reset_accounts=settings.prefer_earlier_reset_accounts,
- prefer_earlier_reset_window=_prefer_earlier_reset_window(settings),
- routing_strategy=_routing_strategy(settings),
- model=session.request_model,
- service_tier=session.request_service_tier,
- exclude_account_ids=excluded_account_ids,
- preferred_account_id=preferred_candidate_id,
- preferred_account_is_continuity_owner=account_neutral_recovery,
- require_security_work_authorized=require_security_work_authorized,
- lease_kind=None if reuse_current_account_lease else "stream",
- estimated_lease_tokens=_estimated_lease_tokens_from_request_usage_budget(
- request_state.request_usage_budget
- ),
- fallback_on_preferred_account_unavailable=(
- not reuse_current_account_lease
- and not hard_close_account_bound
- and required_preferred_account_id is None
- ),
- )
+ try:
+ selection = await self._select_account_with_budget_for_stream(
+ deadline,
+ request_id=request_state.request_log_id or request_state.request_id,
+ kind="http_bridge",
+ request_stage="reattach",
+ api_key=session.api_key,
+ affinity_policy=selection_affinity or session.affinity,
+ prefer_earlier_reset_accounts=settings.prefer_earlier_reset_accounts,
+ prefer_earlier_reset_window=_prefer_earlier_reset_window(settings),
+ routing_strategy=_routing_strategy(settings),
+ model=session.request_model,
+ service_tier=session.request_service_tier,
+ exclude_account_ids=excluded_account_ids,
+ preferred_account_id=preferred_candidate_id,
+ preferred_account_is_continuity_owner=account_neutral_recovery,
+ require_security_work_authorized=require_security_work_authorized,
+ lease_kind=None if reuse_current_account_lease else "stream",
+ estimated_lease_tokens=_estimated_lease_tokens_from_request_usage_budget(
+ request_state.request_usage_budget
+ ),
+ fallback_on_preferred_account_unavailable=(
+ not reuse_current_account_lease
+ and not hard_close_account_bound
+ and required_preferred_account_id is None
+ ),
+ )
+ except BaseException:
+ complete_failed_handoff()
+ raise
account = selection.account
if account is None:
- await release_selected_account_lease()
+ try:
+ await release_selected_account_lease()
+ except BaseException:
+ complete_failed_handoff()
+ raise
if account_neutral_recovery and selection.error_code == CONTINUITY_OWNER_UNAVAILABLE:
+ complete_failed_handoff()
raise _http_bridge_previous_response_owner_unavailable_error()
if (
reuse_current_account_lease
@@ -2137,23 +2200,27 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
):
preferred_candidate_id = None
continue
- if await _sleep_for_account_selection_recovery(
- selection,
- request_id=request_state.request_log_id or request_state.request_id,
- kind="http_bridge",
- request_stage="reattach",
- model=session.request_model,
- max_sleep_seconds=_remaining_budget_seconds(deadline),
- request_state=request_state,
- ):
+ try:
+ should_retry_selection = await _sleep_for_account_selection_recovery(
+ selection,
+ request_id=request_state.request_log_id or request_state.request_id,
+ kind="http_bridge",
+ request_stage="reattach",
+ model=session.request_model,
+ max_sleep_seconds=_remaining_budget_seconds(deadline),
+ request_state=request_state,
+ )
+ except BaseException:
+ complete_failed_handoff()
+ raise
+ if should_retry_selection:
excluded_account_ids.update(request_state.excluded_account_ids)
if required_preferred_account_id in excluded_account_ids:
+ complete_failed_handoff()
raise _http_bridge_previous_response_owner_unavailable_error()
if skip_same_account:
excluded_account_ids.add(session.account.id)
- _require_http_bridge_bound_account_not_excluded(
- hard_close_account_bound, session.account.id, excluded_account_ids
- )
+ require_bound_account()
retry_same_account_once = not skip_same_account and session.account.id not in excluded_account_ids
if skip_same_account:
preferred_candidate_id = None
@@ -2172,6 +2239,7 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
continue
record_selected_account_takeover(None)
status_code = 429 if _is_local_account_cap_code(selection.error_code) else 503
+ complete_failed_handoff()
raise ProxyResponseError(
status_code,
openai_error(
@@ -2182,8 +2250,10 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
)
if required_preferred_account_id is not None and account.id != required_preferred_account_id:
if selection.lease is not None:
- await self._load_balancer.release_account_lease(selection.lease)
+ selected_account_lease = selection.lease
+ await release_selected_account_lease()
record_selected_account_takeover(account.id, required_preferred_account_id)
+ complete_failed_handoff()
raise _http_bridge_previous_response_owner_unavailable_error()
selected_account_lease = (
session.account_lease
@@ -2214,18 +2284,14 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
session.headers,
None if owner_rebind_affinity is not None else _preferred_http_bridge_reconnect_turn_state(session),
)
- upstream = await self._open_upstream_websocket_with_budget(
- account,
- connect_headers,
- timeout_seconds=_remaining_budget_seconds(deadline),
- request_state=request_state,
- )
+ upstream = await open_replacement_upstream(account, connect_headers)
_copy_websocket_route_metadata_to_session(session, request_state)
record_selected_account_takeover(account.id)
break
except ProxyResponseError as exc:
if exc.status_code != 401 or _remaining_budget_seconds(deadline) <= 0:
await release_selected_account_lease()
+ complete_failed_handoff()
raise
try:
account = await self._ensure_fresh_with_budget(
@@ -2241,18 +2307,14 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
else _preferred_http_bridge_reconnect_turn_state(session)
),
)
- upstream = await self._open_upstream_websocket_with_budget(
- account,
- connect_headers,
- timeout_seconds=_remaining_budget_seconds(deadline),
- request_state=request_state,
- )
+ upstream = await open_replacement_upstream(account, connect_headers)
_copy_websocket_route_metadata_to_session(session, request_state)
record_selected_account_takeover(account.id)
break
except ProxyResponseError as retry_exc:
if retry_exc.status_code != 401:
await release_selected_account_lease()
+ complete_failed_handoff()
raise
await self._handle_proxy_error(account, retry_exc)
await abandon_selected_account_retry(account)
@@ -2273,6 +2335,7 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
await abandon_selected_account_retry(account)
continue
await release_selected_account_lease()
+ complete_failed_handoff()
raise
except (aiohttp.ClientError, asyncio.TimeoutError):
if selected_is_preferred and _remaining_budget_seconds(deadline) > 0:
@@ -2283,34 +2346,72 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
await abandon_selected_account_retry(account)
continue
await release_selected_account_lease()
+ complete_failed_handoff()
raise
- if owner_rebind_affinity is not None:
- await self._claim_http_bridge_replacement_before_swap(
- session,
- account_id=account.id,
- upstream=upstream,
- release_selected_account_lease=release_selected_account_lease,
- owner_rebind_affinity=owner_rebind_affinity,
- )
- await self._unregister_http_bridge_turn_states(session)
- await self._unregister_http_bridge_previous_response_ids(session)
- session.last_completed_response_id = None
- session.last_completed_input_count = 0
- session.last_completed_input_prefix_fingerprint = None
- session.last_pending_tool_calls.clear()
- session.affinity = selection_affinity or session.affinity
- session.codex_session = False
- session.upstream_turn_state = None
- session.downstream_turn_state = None
- session.headers = {
- key: value for key, value in session.headers.items() if key.lower() != "x-codex-turn-state"
- }
+ except asyncio.CancelledError:
+ session.closed = True
+ await release_selected_account_lease()
+ complete_failed_handoff()
+ raise
+ except BaseException:
+ await release_selected_account_lease()
+ complete_failed_handoff()
+ raise
+
+ async def abort_selected_handoff() -> None:
+ session.closed = True
+ try:
+ await asyncio.shield(upstream.close())
+ except BaseException:
+ logger.debug("Failed to close HTTP bridge replacement websocket", exc_info=True)
+ selected_lease = selected_account_lease
+ old_lease = session.account_lease
+ try:
+ await asyncio.shield(release_selected_account_lease())
+ except BaseException:
+ logger.debug("Failed to release HTTP bridge replacement lease", exc_info=True)
+ if old_lease is not None and old_lease is not selected_lease:
+ session.account_lease = None
+ try:
+ await asyncio.shield(self._load_balancer.release_account_lease(old_lease))
+ except BaseException:
+ logger.debug("Failed to release HTTP bridge old account lease", exc_info=True)
+ complete_failed_handoff()
+
try:
- await old_upstream.close()
- except Exception:
- logger.debug("Failed to close HTTP bridge upstream websocket before reconnect", exc_info=True)
- async with session.pending_lock:
- replaced_account_lease = session.account_lease
+ if owner_rebind_affinity is not None:
+ await self._claim_http_bridge_replacement_before_swap(
+ session,
+ account_id=account.id,
+ upstream=upstream,
+ release_selected_account_lease=release_selected_account_lease,
+ owner_rebind_affinity=owner_rebind_affinity,
+ )
+ if owner_rebind_affinity is not None or account.id != session.account.id:
+ await self._unregister_http_bridge_turn_states(session)
+ await self._unregister_http_bridge_previous_response_ids(session)
+ session.last_completed_response_id = None
+ session.last_completed_input_count = 0
+ session.last_completed_input_prefix_fingerprint = None
+ session.last_pending_tool_calls.clear()
+ session.affinity = selection_affinity or session.affinity
+ session.codex_session = False
+ session.upstream_turn_state = None
+ session.downstream_turn_state = None
+ session.headers = {
+ key: value for key, value in session.headers.items() if key.lower() != "x-codex-turn-state"
+ }
+ await _persist_http_bridge_replacement_account(self, session, account.id)
+ try:
+ await old_upstream.close()
+ except Exception:
+ logger.debug("Failed to close HTTP bridge upstream websocket before reconnect", exc_info=True)
+ session.closed = True
+ if selected_account_lease is not session.account_lease:
+ old_lease = session.account_lease
+ if old_lease is not None:
+ await self._load_balancer.release_account_lease(old_lease)
+ session.account_lease = None
session.account_lease = selected_account_lease
session.account, session.headers, session.upstream = account, connect_headers, upstream
session.catalog_omission_quota_admission = selection.catalog_omission_quota_admission
@@ -2318,13 +2419,10 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
session.closed = False
session.last_upstream_close_code = None
session.upstream_turn_state = _upstream_turn_state_from_socket(upstream) or session.upstream_turn_state
- if replaced_account_lease is not None and (
- selected_account_lease is None or selected_account_lease.lease_id != replaced_account_lease.lease_id
- ):
- release_task = asyncio.create_task(self._load_balancer.release_account_lease(replaced_account_lease))
- _, cancellation = await _await_task_deferring_cancellation(release_task)
- if cancellation is not None:
- raise cancellation
+ _complete_http_bridge_handoff(session, self._http_bridge_inflight_sessions)
+ except BaseException:
+ await abort_selected_handoff()
+ raise
if restart_reader:
session.upstream_reader = asyncio.create_task(self._relay_http_bridge_upstream_messages(session))
_log_http_bridge_event(
@@ -2332,11 +2430,7 @@ async def abandon_selected_account_retry(selected_account: Any) -> None:
session.key,
account_id=account.id,
model=session.request_model,
- detail=(
- f"request_stage=reattach, previous_account={old_account_id}, "
- f"preferred_account_id={old_account_id}, selected_account_id={account.id}, "
- f"durable_session_id={session.durable_session_id}"
- ),
+ detail=f"selected_account_id={account.id}, durable_session_id={session.durable_session_id}",
cache_key_family=session.key.affinity_kind,
model_class=_extract_model_class(session.request_model) if session.request_model else None,
)
diff --git a/app/modules/proxy/_service/http_bridge/request_submit.py b/app/modules/proxy/_service/http_bridge/request_submit.py
index 0319e918ea..8e4f13f608 100644
--- a/app/modules/proxy/_service/http_bridge/request_submit.py
+++ b/app/modules/proxy/_service/http_bridge/request_submit.py
@@ -3,6 +3,8 @@
import asyncio
import json
import logging
+import math
+import random
from collections import deque
from dataclasses import replace
from typing import Any, Literal, Mapping, cast
@@ -70,6 +72,7 @@
from app.modules.proxy._service.http_bridge.helpers import (
_await_task_deferring_cancellation,
_build_http_bridge_prewarm_text,
+ _http_bridge_durable_lease_ttl_seconds,
_http_bridge_key_strength,
_http_bridge_precreated_retry_failure_error,
_http_bridge_prewarm_enabled,
@@ -125,6 +128,7 @@
_truncate_identifier as _truncate_identifier,
)
from app.modules.proxy._service.support import (
+ _ACCOUNT_MODEL_UNSUPPORTED_ERROR_CODE,
_HARD_HTTP_BRIDGE_AFFINITY_KINDS, # noqa: F401
_WEBSOCKET_FULL_REPLAY_WAIT_POLL_SECONDS, # noqa: F401
_clear_websocket_request_error_overrides,
@@ -172,12 +176,14 @@
_AffinityPolicy,
_extract_model_class,
_owner_lookup_session_id_from_headers,
+ _sticky_key_from_turn_state_header,
)
from app.modules.proxy.api_key_usage import estimate_api_key_request_usage
from app.modules.proxy.continuity import is_http_bridge_account_neutral_replay
from app.modules.proxy.durable_bridge_repository import (
DurableBridgeAliasRegistration,
DurableBridgeAliasRegistrationReceipt,
+ durable_bridge_hash,
)
from app.modules.proxy.helpers import (
_normalize_error_code,
@@ -189,6 +195,10 @@
)
logger = logging.getLogger("app.modules.proxy.service")
+
+_HTTP_BRIDGE_CLEAN_CLOSE_RETRY_MAX_COUNT = 1
+_HTTP_BRIDGE_CLEAN_CLOSE_RETRY_JITTER_MAX_SECONDS = 2.0
+
_REQUEST_TRANSPORT_HTTP = "http"
_WEBSOCKET_AUTH_INVALIDATED_FAILURE_CODE = "account_auth_invalidated"
_NO_SECURITY_WORK_AUTHORIZED_ACCOUNTS_CODE = "no_security_work_authorized_accounts"
@@ -273,6 +283,30 @@ def _request_kind_from_headers(headers: Mapping[str, str] | None) -> str:
class _HTTPBridgeRequestSubmitMixin:
+ @staticmethod
+ def _http_bridge_clean_close_retry_max_count() -> int:
+ configured = _HTTP_BRIDGE_CLEAN_CLOSE_RETRY_MAX_COUNT
+ # Keep this recovery bounded even if an unsafe higher value is supplied.
+ return max(0, min(1, configured))
+
+ @staticmethod
+ def _http_bridge_clean_close_retry_jitter_seconds() -> float:
+ settings = _service_get_settings()
+ maximum = max(
+ 0.0,
+ min(
+ 30.0,
+ float(
+ getattr(
+ settings,
+ "http_responses_session_bridge_clean_close_retry_jitter_max_seconds",
+ _HTTP_BRIDGE_CLEAN_CLOSE_RETRY_JITTER_MAX_SECONDS,
+ )
+ ),
+ ),
+ )
+ return random.uniform(0.0, maximum) if maximum > 0 else 0.0
+
def _prepare_http_bridge_request(
self: Any,
payload: ResponsesRequest,
@@ -383,6 +417,10 @@ def _prepare_response_bridge_request_state(
request_usage_budget=estimate_api_key_request_usage(payload),
previous_response_id=payload.previous_response_id,
session_id=_normalize_session_id(session_id),
+ hard_continuity_anchor=(
+ payload.previous_response_id is not None
+ or _sticky_key_from_turn_state_header(headers or {}) is not None
+ ),
input_item_count=input_item_count,
input_full_fingerprint=input_full_fingerprint,
request_kind=request_kind,
@@ -550,6 +588,135 @@ async def _submit_http_bridge_request_with_handoff(
request_scope_id: str,
recovery_turn_state: str | None = None,
) -> None:
+ # Eventless upstream timeouts retire the current socket. A client
+ # reconnect can otherwise create a fresh socket for the same hard key
+ # and submit the identical request repeatedly while the retry circuit
+ # is cooling down. Gate new submissions before any reconnect/send so
+ # the circuit turns this into a bounded 503 instead of another
+ # response.create attempt. A proof-gated full resend remains allowed
+ # because it is the client's own replay-safe request, not an opaque
+ # continuation replay.
+ allow_proof_gated_continuity_replay = bool(
+ request_state.previous_response_id is not None
+ and request_state.fresh_upstream_request_is_retry_safe
+ and request_state.fresh_upstream_request_text
+ and request_state.response_event_count == 0
+ and request_state.replay_count == 0
+ )
+ if not await self._http_bridge_precreated_retry_allowed(
+ session,
+ allow_proof_gated_continuity_replay=allow_proof_gated_continuity_replay,
+ ):
+ retry_after_seconds = max(
+ 1,
+ math.ceil(await self._http_bridge_precreated_retry_cooldown_seconds(session)),
+ )
+ _log_http_bridge_event(
+ "submit_retry_circuit_suppressed",
+ session.key,
+ account_id=session.account.id,
+ model=session.request_model,
+ detail="hard_key_cooldown",
+ cache_key_family=session.key.affinity_kind,
+ model_class=_extract_model_class(session.request_model) if session.request_model else None,
+ )
+ raise ProxyResponseError(
+ 503,
+ openai_error(
+ "upstream_request_timeout",
+ "HTTP responses session bridge is cooling down after repeated upstream timeouts; retry shortly.",
+ ),
+ retry_after_seconds=retry_after_seconds,
+ )
+ # Persist the recovery checkpoint only after the retry circuit has
+ # admitted this request. A client reconnect suppressed by the
+ # cooldown must not create or refresh a journal entry for a request
+ # that was never dispatched upstream.
+ if (
+ request_state.fresh_upstream_request_is_retry_safe
+ and request_state.fresh_upstream_request_text
+ and request_state.replay_count == 0
+ and request_state.recovery_attempt_fingerprint is None
+ and session.durable_session_id is not None
+ and session.durable_owner_epoch is not None
+ ):
+ attempt_fingerprint = durable_bridge_hash(request_state.fresh_upstream_request_text)
+ try:
+ attempt = await self._durable_bridge.record_recovery_attempt(
+ session_id=session.durable_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=session.durable_owner_epoch,
+ request_fingerprint=attempt_fingerprint,
+ request_id=request_state.request_id,
+ account_id=session.account.id,
+ model=request_state.model,
+ replay_safe=True,
+ )
+ if attempt is None:
+ # ``None`` is the durable owner fence rejecting this
+ # worker, not an unavailable journal (which raises and
+ # is handled below). Never dispatch from a stale owner.
+ session.closed = True
+ session.upstream_control.reconnect_requested = True
+ session.upstream_control.retire_after_drain = True
+ _record_continuity_fail_closed(
+ surface="http_bridge",
+ reason="recovery_attempt_owner_fence_rejected",
+ previous_response_id=request_state.previous_response_id,
+ session_id=request_state.session_id,
+ upstream_error_code="bridge_continuity_persistence_failed",
+ )
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses session ownership changed; retry the request.",
+ ),
+ )
+ if getattr(attempt.state, "value", attempt.state) != "unknown":
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "The recovery checkpoint was already consumed; retry the request.",
+ ),
+ )
+ if getattr(attempt, "request_id", request_state.request_id) != request_state.request_id:
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "Another recovery request is already in flight; retry the request.",
+ ),
+ )
+ request_state.recovery_attempt_fingerprint = attempt_fingerprint
+ request_state.recovery_attempt_session_id = session.durable_session_id
+ request_state.recovery_attempt_owner_epoch = session.durable_owner_epoch
+ except ProxyResponseError:
+ raise
+ except Exception as exc:
+ # The journal is an additional recovery fence. Without a
+ # durable UNKNOWN row, an ambiguous send cannot be claimed by
+ # another owner, so fail closed instead of dispatching an
+ # unjournaled recovery-safe request.
+ session.closed = True
+ session.upstream_control.reconnect_requested = True
+ session.upstream_control.retire_after_drain = True
+ _record_continuity_fail_closed(
+ surface="http_bridge",
+ reason="recovery_attempt_persistence_failed",
+ previous_response_id=request_state.previous_response_id,
+ session_id=request_state.session_id,
+ upstream_error_code="bridge_continuity_persistence_failed",
+ )
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "Recovered response continuity could not be persisted; retry the request.",
+ ),
+ ) from exc
text_data = self._http_bridge_text_with_account_installation_id(session, request_state, text_data)
if request_state.response_id is not None or request_state.response_event_count > 0:
_log_http_bridge_event(
@@ -728,6 +895,7 @@ async def _submit_http_bridge_request_with_handoff(
)
_copy_websocket_route_metadata_from_session(request_state, session)
request_state.bridge_queue_wait_started_at = _service_time().monotonic()
+ request_state.response_create_gate_wait_started_at = _service_time().monotonic()
# Bridge ownership is established before this late admission. A
# cap race stays a bounded error/wait on that owner; it must not
# publish a replacement bridge as a spillover side effect.
@@ -868,6 +1036,107 @@ async def _submit_http_bridge_request_with_handoff(
"Recovered response continuity could not be persisted; retry the request.",
),
)
+ if request_state.recovery_attempt_fingerprint is not None:
+ try:
+ owner_lookup = await self._durable_bridge.renew_live_session(
+ session_id=session.durable_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=session.durable_owner_epoch,
+ lease_ttl_seconds=_http_bridge_durable_lease_ttl_seconds(),
+ latest_turn_state=session.downstream_turn_state,
+ latest_response_id=None,
+ )
+ except Exception as exc:
+ session.closed = True
+ session.upstream_control.reconnect_requested = True
+ session.upstream_control.retire_after_drain = True
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses session ownership could not be renewed; retry the request.",
+ ),
+ ) from exc
+ if (
+ owner_lookup is None
+ or owner_lookup.owner_instance_id
+ != _service_get_settings().http_responses_session_bridge_instance_id
+ or owner_lookup.owner_epoch != session.durable_owner_epoch
+ ):
+ session.closed = True
+ session.upstream_control.reconnect_requested = True
+ session.upstream_control.retire_after_drain = True
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses session ownership changed before dispatch; retry the request.",
+ ),
+ )
+ # The journal entry is created before queue admission so
+ # concurrent requests cannot both enter the gate without
+ # a recovery generation. Revalidate it after the gate and
+ # lifecycle locks, immediately before dispatch: a waiter
+ # may have observed UNKNOWN while the first request
+ # settled the row REPLAYED.
+ if (
+ request_state.recovery_attempt_fingerprint is not None
+ and not request_state.recovery_attempt_claimed
+ ):
+ if (
+ request_state.recovery_attempt_session_id != session.durable_session_id
+ or request_state.recovery_attempt_owner_epoch != session.durable_owner_epoch
+ ):
+ session.closed = True
+ session.upstream_control.reconnect_requested = True
+ session.upstream_control.retire_after_drain = True
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses session ownership changed before dispatch; retry the request.",
+ ),
+ )
+ try:
+ dispatch_attempt = await self._durable_bridge.record_recovery_attempt(
+ session_id=session.durable_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=session.durable_owner_epoch,
+ request_fingerprint=request_state.recovery_attempt_fingerprint,
+ request_id=request_state.request_id,
+ account_id=session.account.id,
+ model=request_state.model,
+ replay_safe=True,
+ )
+ except Exception as exc:
+ session.closed = True
+ session.upstream_control.reconnect_requested = True
+ session.upstream_control.retire_after_drain = True
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "Recovered response continuity could not be revalidated; retry the request.",
+ ),
+ ) from exc
+ if (
+ dispatch_attempt is None
+ or getattr(dispatch_attempt.state, "value", dispatch_attempt.state) != "unknown"
+ or getattr(dispatch_attempt, "request_id", request_state.request_id)
+ != request_state.request_id
+ ):
+ session.closed = True
+ session.upstream_control.reconnect_requested = True
+ session.upstream_control.retire_after_drain = True
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "The recovery checkpoint was consumed before dispatch; retry the request.",
+ ),
+ )
async with session.pending_lock:
session.pending_requests.append(request_state)
session.admission_waiter_count = max(0, session.admission_waiter_count - 1)
@@ -877,6 +1146,7 @@ async def _submit_http_bridge_request_with_handoff(
try:
await _send_http_bridge_request_text_with_archive_id(session, request_state, text_data)
except BaseException:
+ request_state.recovery_attempt_dispatched = True
# Publish retirement while lifecycle ownership is still
# held; a gate waiter must never reuse an ambiguously sent
# response.create socket between unlock and cleanup.
@@ -884,6 +1154,7 @@ async def _submit_http_bridge_request_with_handoff(
session.upstream_control.reconnect_requested = True
session.upstream_control.retire_after_drain = True
raise
+ request_state.recovery_attempt_dispatched = True
session.last_used_at = _service_time().monotonic()
except asyncio.CancelledError:
if recovery_receipt is not None and not upstream_send_started:
@@ -1257,6 +1528,10 @@ async def _cleanup_http_bridge_submit_interruption(
await self._retire_stale_pending_http_bridge_session(
session,
detail="last_admission_waiter_cancelled",
+ response_events_seen=max(
+ request_state.response_event_count,
+ int(request_state.response_id is not None or request_state.latency_response_created_ms is not None),
+ ),
)
await self._maybe_release_idle_http_bridge_session_lease(session)
@@ -1400,6 +1675,78 @@ async def _detach_http_bridge_request(
await self._retire_http_bridge_after_drain_if_ready(session)
return True
+ async def _fail_stale_http_bridge_pending_requests(
+ self: Any,
+ session: "_HTTPBridgeSession",
+ request_states: list[_WebSocketRequestState],
+ *,
+ detail: str,
+ ) -> None:
+ stale_requests: deque[_WebSocketRequestState] = deque()
+ response_events_seen = 0
+ async with session.pending_lock:
+ for request_state in request_states:
+ if request_state not in session.pending_requests:
+ continue
+ response_events_seen = max(
+ response_events_seen,
+ request_state.response_event_count,
+ int(
+ request_state.response_id is not None
+ or request_state.latency_response_created_ms is not None
+ or request_state.downstream_visible
+ ),
+ )
+ session.pending_requests.remove(request_state)
+ if _http_bridge_request_counts_against_queue(request_state):
+ session.queued_request_count = max(0, session.queued_request_count - 1)
+ stale_requests.append(request_state)
+ if not stale_requests:
+ return
+ if response_events_seen == 0:
+ await self._record_http_bridge_retry_circuit_failure(session, detail=detail)
+ await self._fail_pending_websocket_requests(
+ account=session.account,
+ account_id_value=session.account.id,
+ pending_requests=stale_requests,
+ pending_lock=session.pending_lock,
+ error_code="upstream_request_timeout",
+ error_message="HTTP bridge response-create gate holder timed out",
+ api_key=None,
+ response_create_gate=session.response_create_gate,
+ penalize_account=False,
+ )
+
+ def _classify_http_bridge_stale_gate_holders(
+ self: Any,
+ pending_states: list[_WebSocketRequestState],
+ *,
+ now: float,
+ threshold_seconds: float,
+ session_closed: bool,
+ ) -> tuple[list[_WebSocketRequestState], bool]:
+ stale_states = [
+ state
+ for state in pending_states
+ if not state.draining_until_terminal
+ and self._http_bridge_pending_state_is_stale(
+ state,
+ now=now,
+ threshold_seconds=threshold_seconds,
+ session_closed=session_closed,
+ )
+ ]
+ active_states = [
+ # A draining request still owns terminal response continuity. It
+ # must keep the session alive while stale holders are cleaned up.
+ state
+ for state in pending_states
+ if state not in stale_states
+ ]
+ if stale_states and active_states:
+ return stale_states, False
+ return [], bool(stale_states)
+
async def _retire_http_bridge_after_drain_if_ready(self: Any, session: "_HTTPBridgeSession") -> bool:
if not (session.upstream_control.reconnect_requested and session.upstream_control.retire_after_drain):
return False
@@ -1424,7 +1771,14 @@ async def _retire_stale_pending_http_bridge_session(
session: "_HTTPBridgeSession",
*,
detail: str,
+ retry_circuit_detail: str | None = None,
+ response_events_seen: int | None = None,
) -> None:
+ if response_events_seen is None or response_events_seen == 0:
+ await self._record_http_bridge_retry_circuit_failure(
+ session,
+ detail=retry_circuit_detail or detail,
+ )
session.closed = True
async with self._http_bridge_lock:
if self._http_bridge_sessions.get(session.key) is session:
@@ -1468,16 +1822,14 @@ async def _retry_http_bridge_request_on_fresh_upstream(
# upstream already accepted the continuation. Re-sending the same
# previous_response_id request can fork continuity with duplicate
# child responses, so only reconnect-without-resend is allowed.
- # The single exception is proxy-injected anchors on trim-safe
- # full-resend payloads: dropping the anchor and replaying the
- # original unanchored request is equivalent to the client's own
- # retry. Session-level injections do not opt in because their
- # payload may depend on the anchor for context preservation.
- if (
- not request_state.proxy_injected_previous_response_id
- or not request_state.fresh_upstream_request_text
- or not request_state.fresh_upstream_request_is_retry_safe
- ):
+ # The single exception is a proof-gated, trim-safe full-resend
+ # payload: dropping the anchor and replaying the original
+ # unanchored request is equivalent to the client's own retry.
+ # The proof is independent of where the anchor came from; a
+ # client-provided full resend is as safe as a durable injection.
+ # Session-level follow-ups do not opt in because their context may
+ # depend on the anchor.
+ if not request_state.fresh_upstream_request_text or not request_state.fresh_upstream_request_is_retry_safe:
return False
retry_text_data = request_state.fresh_upstream_request_text
using_fresh_replay = True
@@ -1530,7 +1882,67 @@ async def _retry_http_bridge_precreated_request(
session: "_HTTPBridgeSession",
*,
request_state: _WebSocketRequestState | None = None,
+ restart_reader: bool = False,
) -> bool:
+ clean_close_retry_max_count = self._http_bridge_clean_close_retry_max_count()
+ account_neutral_recovery = is_http_bridge_account_neutral_replay(
+ kind=session.key.affinity_kind,
+ key=session.key.affinity_key,
+ )
+
+ def request_is_retryable(request_state: _WebSocketRequestState) -> bool:
+ if _websocket_request_can_replay_before_visible_output(request_state):
+ return True
+ if (
+ clean_close_retry_max_count <= 0
+ or request_state.replay_count != 1
+ or request_state.response_event_count != 0
+ or request_state.clean_close_replay_count >= clean_close_retry_max_count
+ or _classify_upstream_close(
+ session.last_upstream_close_code,
+ response_events_seen=request_state.response_event_count,
+ )
+ != "clean"
+ ):
+ return False
+ return _websocket_request_can_replay_before_visible_output(
+ request_state,
+ allow_clean_close_retry=True,
+ )
+
+ fresh_hard_request_account_switch_candidate = False
+ proof_gated_continuity_replay_candidate = False
+ if session.key.strength == "hard":
+ async with session.pending_lock:
+ retryable_candidates = [
+ request_state
+ for request_state in session.pending_requests
+ if not request_state.draining_until_terminal and request_is_retryable(request_state)
+ ]
+ if len(retryable_candidates) == 1:
+ candidate = retryable_candidates[0]
+ fresh_hard_request_account_switch_candidate = (
+ candidate.previous_response_id is None
+ and not candidate.hard_continuity_anchor
+ and not candidate.proxy_injected_previous_response_id
+ and not candidate.file_required_preferred_account
+ and candidate.response_event_count == 0
+ and candidate.replay_count == 0
+ )
+ proof_gated_continuity_replay_candidate = (
+ candidate.previous_response_id is not None
+ and candidate.fresh_upstream_request_is_retry_safe
+ and bool(candidate.fresh_upstream_request_text)
+ and candidate.response_event_count == 0
+ and candidate.replay_count == 0
+ )
+ if not await self._http_bridge_precreated_retry_allowed(
+ session,
+ allow_fresh_hard_account_switch=fresh_hard_request_account_switch_candidate,
+ allow_proof_gated_continuity_replay=proof_gated_continuity_replay_candidate,
+ ):
+ return False
+
account_neutral_recovery = is_http_bridge_account_neutral_replay(
kind=session.key.affinity_kind,
key=session.key.affinity_key,
@@ -1543,41 +1955,62 @@ async def _retry_http_bridge_precreated_request(
or any(pending_request is not request_state for pending_request in session.pending_requests)
or request_state.draining_until_terminal
or not _http_bridge_request_counts_against_queue(request_state)
- or not _websocket_request_can_replay_before_visible_output(request_state)
+ or not request_is_retryable(request_state)
):
return False
else:
retryable_requests = [
request_state
for request_state in session.pending_requests
- if not request_state.draining_until_terminal
- and _websocket_request_can_replay_before_visible_output(request_state)
+ if not request_state.draining_until_terminal and request_is_retryable(request_state)
]
if len(retryable_requests) != 1:
return False
request_state = retryable_requests[0]
+ model_fallback_replay = request_state.precreated_replay_reason == _ACCOUNT_MODEL_UNSUPPORTED_ERROR_CODE
if request_state.previous_response_id is not None and not (
- request_state.proxy_injected_previous_response_id
- and request_state.fresh_upstream_request_is_retry_safe
- and request_state.fresh_upstream_request_text
+ request_state.fresh_upstream_request_is_retry_safe and request_state.fresh_upstream_request_text
):
# Once a continuation is pending upstream, reconnecting without
# replay cannot complete the current request, while replaying it
# is unsafe without upstream idempotency guarantees. Proxy-
- # injected retry-safe anchors are equivalent to the client's own
- # full resend once the anchor is stripped.
+ # injected anchors and proof-gated client full resends are
+ # equivalent to the client's own retry once the anchor is
+ # stripped. The latter remains pinned to the current owner.
return False
close_classification = _classify_upstream_close(
session.last_upstream_close_code,
response_events_seen=request_state.response_event_count,
)
- if close_classification == "rejected":
- request_state.error_code_override = "upstream_rejected_input"
- request_state.error_http_status_override = 502
- request_state.error_message_override = (
- "Upstream rejected the request before response.created "
- f"(close_code={session.last_upstream_close_code})"
- )
+ close_generation = session.last_upstream_close_generation
+ hard_session_affinity = session.key.strength == "hard"
+ fresh_hard_request_account_switch_allowed = (
+ hard_session_affinity
+ and request_state.previous_response_id is None
+ and not request_state.hard_continuity_anchor
+ and not request_state.proxy_injected_previous_response_id
+ and not request_state.file_required_preferred_account
+ )
+ clean_close_hard_continuation = (
+ close_classification == "clean"
+ and hard_session_affinity
+ and request_state.previous_response_id is not None
+ )
+ clean_close_hard_continuity_anchor = (
+ close_classification == "clean" and hard_session_affinity and request_state.hard_continuity_anchor
+ )
+ clean_close_retry_for_current_close = (
+ close_classification == "clean"
+ and request_state.clean_close_retry_close_generation != close_generation
+ and not request_state.clean_close_retry_in_progress
+ )
+ additional_clean_close_retry = (
+ clean_close_retry_for_current_close
+ and request_state.replay_count == 1
+ and request_state.response_event_count == 0
+ and request_state.clean_close_replay_count < clean_close_retry_max_count
+ )
+ if request_state.replay_count >= 1 and not additional_clean_close_retry:
return False
if request_state.previous_response_id is not None:
require_preferred_reconnect = False
@@ -1607,23 +2040,41 @@ async def _retry_http_bridge_precreated_request(
if not hard_owner_bound:
request_state.excluded_account_ids.add(session.account.id)
else:
- require_preferred_reconnect = account_neutral_recovery
+ # Account-scoped uploaded files cannot be replayed on a
+ # different owner. Keep the preferred account mandatory for
+ # both silent recovery and clean-close recovery.
+ require_preferred_reconnect = account_neutral_recovery or request_state.file_required_preferred_account
request_text = _prepare_websocket_request_state_for_visible_output_replay(request_state)
if request_text is None:
return False
if account_neutral_recovery:
request_state.preferred_account_id = session.account.id
- elif not request_state.file_required_preferred_account and not hard_owner_bound:
- request_state.preferred_account_id = None
- request_state.excluded_account_ids.add(session.account.id)
+ elif not request_state.file_required_preferred_account:
+ if hard_owner_bound and not model_fallback_replay and not fresh_hard_request_account_switch_allowed:
+ request_state.preferred_account_id = session.account.id
+ else:
+ request_state.preferred_account_id = None
+ request_state.excluded_account_ids.add(session.account.id)
if session.account.id in request_state.excluded_account_ids:
session.upstream_turn_state = None
session.downstream_turn_state = None
session.headers = {
key: value for key, value in session.headers.items() if key.lower() != "x-codex-turn-state"
}
+ if close_classification == "clean":
+ if not clean_close_retry_for_current_close:
+ return False
+ request_state.clean_close_retry_in_progress = True
+ request_state.clean_close_retry_result = None
+ request_state.clean_close_retry_close_generation = close_generation
+ if additional_clean_close_retry:
+ request_state.clean_close_replay_count += 1
+ retry_jitter_seconds = (
+ self._http_bridge_clean_close_retry_jitter_seconds() if additional_clean_close_retry else 0.0
+ )
+ retry_event = "retry_precreated_clean_close" if additional_clean_close_retry else "retry_precreated"
_log_http_bridge_event(
- "retry_precreated",
+ retry_event,
session.key,
account_id=session.account.id,
model=session.request_model,
@@ -1631,12 +2082,47 @@ async def _retry_http_bridge_precreated_request(
cache_key_family=session.key.affinity_kind,
model_class=_extract_model_class(session.request_model) if session.request_model else None,
)
+ reconnect_reader_kwargs = {"restart_reader": True} if restart_reader else {}
try:
- if hard_owner_bound:
+ if retry_jitter_seconds > 0:
+ logger.info(
+ "HTTP bridge clean-close retry jitter request_id=%s sleep_seconds=%.3f",
+ request_state.request_id,
+ retry_jitter_seconds,
+ )
+ await asyncio.sleep(retry_jitter_seconds)
+ request_deadline = request_state.bridge_request_deadline
+ if request_deadline is None:
+ request_deadline = request_state.started_at + _http_bridge_request_budget_seconds(
+ _service_get_settings()
+ )
+ now_monotonic = _service_time().monotonic()
+ async with session.pending_lock:
+ request_still_owned = (
+ request_state in session.pending_requests and not request_state.draining_until_terminal
+ )
+ if not request_still_owned or now_monotonic >= request_deadline:
+ logger.info(
+ "HTTP bridge clean-close retry abandoned after jitter request_id=%s "
+ "still_owned=%s deadline_expired=%s",
+ request_state.request_id,
+ request_still_owned,
+ now_monotonic >= request_deadline,
+ )
+ request_state.clean_close_retry_result = False
+ return False
+ # A fresh hard-session replay may select a replacement account.
+ # The admission lease is account-scoped, so release the old
+ # account's lease before reconnecting; the post-reconnect path
+ # below acquires a lease for the account actually selected.
+ if fresh_hard_request_account_switch_allowed:
+ await self._release_request_state_account_response_create_lease(request_state)
+ if hard_owner_bound and not model_fallback_replay and not fresh_hard_request_account_switch_allowed:
await self._reconnect_http_bridge_session(
session,
request_state=request_state,
require_same_account=True,
+ **reconnect_reader_kwargs,
)
elif require_preferred_reconnect:
await self._reconnect_http_bridge_session(
@@ -1644,12 +2130,23 @@ async def _retry_http_bridge_precreated_request(
request_state=request_state,
require_same_account=account_neutral_recovery,
require_preferred_account=True,
+ **reconnect_reader_kwargs,
+ )
+ elif clean_close_hard_continuation or clean_close_hard_continuity_anchor:
+ await self._reconnect_http_bridge_session(
+ session,
+ request_state=request_state,
+ # Continuity anchors (previous_response_id and turn-state)
+ # are account-bound. Do not migrate them while recovering a
+ # clean handoff close.
+ require_same_account=True,
+ **reconnect_reader_kwargs,
)
else:
await self._reconnect_http_bridge_session(
session,
request_state=request_state,
- require_same_account=account_neutral_recovery,
+ **reconnect_reader_kwargs,
)
if request_state.account_response_create_lease is None:
current_settings = await _service_get_settings_cache().get()
@@ -1703,10 +2200,16 @@ async def _retry_http_bridge_precreated_request(
request_text = self._http_bridge_text_with_account_installation_id(session, request_state, request_text)
await _send_http_bridge_request_text_with_archive_id(session, request_state, request_text)
session.last_used_at = _service_time().monotonic()
+ request_state.clean_close_retry_result = True
return True
+ except asyncio.CancelledError:
+ request_state.clean_close_retry_result = False
+ raise
except UpstreamWebSocketTransportError:
+ request_state.clean_close_retry_result = False
raise
except Exception as exc:
+ request_state.clean_close_retry_result = False
(
request_state.error_http_status_override,
request_state.error_code_override,
@@ -1723,6 +2226,8 @@ async def _retry_http_bridge_precreated_request(
else:
logger.warning("HTTP bridge pre-created retry failed", exc_info=True)
return False
+ finally:
+ request_state.clean_close_retry_in_progress = False
async def _retry_http_bridge_precreated_auth_request(
self: Any,
diff --git a/app/modules/proxy/_service/http_bridge/retry_circuit.py b/app/modules/proxy/_service/http_bridge/retry_circuit.py
new file mode 100644
index 0000000000..5a21c10332
--- /dev/null
+++ b/app/modules/proxy/_service/http_bridge/retry_circuit.py
@@ -0,0 +1,433 @@
+from __future__ import annotations
+
+import logging
+import time
+from dataclasses import dataclass
+from typing import Any
+
+import anyio
+
+from app.core.metrics.prometheus import PROMETHEUS_AVAILABLE, http_bridge_retry_circuit_total
+from app.modules.proxy._service.observability import _hash_identifier
+from app.modules.proxy._service.support import _HTTPBridgeSession
+from app.modules.proxy.durable_bridge_repository import DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS
+
+logger = logging.getLogger(__name__)
+
+_HTTP_BRIDGE_RETRY_CIRCUIT_FAILURE_THRESHOLD = 2
+_HTTP_BRIDGE_RETRY_CIRCUIT_BASE_BACKOFF_SECONDS = 60.0
+_HTTP_BRIDGE_RETRY_CIRCUIT_MAX_BACKOFF_SECONDS = 600.0
+_HTTP_BRIDGE_RETRY_CIRCUIT_CLEAN_CLOSE_MAX_BACKOFF_SECONDS = 30.0
+_HTTP_BRIDGE_RETRY_CIRCUIT_HALF_OPEN_LEASE_SECONDS = 600.0
+_HTTP_BRIDGE_RETRY_CIRCUIT_FAILURE_DETAILS = frozenset(
+ {
+ "stream_incomplete",
+ "clean_close",
+ "stream_idle_timeout",
+ }
+)
+_HTTP_BRIDGE_RETRY_CIRCUIT_DETAIL_ALIASES = {
+ # These diagnostics describe the same ambiguous idle/incomplete
+ # transport class. Keep the durable contract to the three documented
+ # failure classes while retaining the more specific event in logs.
+ "upstream_keepalive_timeout": "stream_idle_timeout",
+ "missing_response_created_timeout": "stream_idle_timeout",
+ "response_create_gate_timeout_stuck_pending": "stream_idle_timeout",
+}
+
+
+@dataclass(slots=True)
+class _HTTPBridgeRetryCircuitState:
+ consecutive_failures: int = 0
+ cooldown_until: float = 0.0
+ last_detail: str | None = None
+ last_touched_monotonic: float = 0.0
+ persisted_updated_at_epoch: float = 0.0
+ last_failure_monotonic: float = 0.0
+ last_durable_load_monotonic: float = 0.0
+ half_open_until: float = 0.0
+
+
+def _initialize_http_bridge_retry_circuit(service: Any) -> None:
+ service._http_bridge_retry_circuits = {}
+ service._http_bridge_retry_circuit_loaded_keys = set()
+ service._http_bridge_retry_circuit_persisted_keys = set()
+ service._http_bridge_retry_circuit_lock = anyio.Lock()
+
+
+class _HTTPBridgeRetryCircuitMixin:
+ def _prune_http_bridge_retry_circuit_state(self: Any, now: float) -> None:
+ expiry = now - DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS
+ for key, state in list(self._http_bridge_retry_circuits.items()):
+ if state.last_touched_monotonic > expiry:
+ continue
+ self._http_bridge_retry_circuits.pop(key, None)
+ self._http_bridge_retry_circuit_loaded_keys.discard(key)
+ self._http_bridge_retry_circuit_persisted_keys.discard(key)
+
+ async def _load_http_bridge_retry_circuit(self: Any, session: _HTTPBridgeSession) -> bool:
+ if session.key.strength != "hard":
+ return True
+
+ now_monotonic = time.monotonic()
+ async with self._http_bridge_retry_circuit_lock:
+ self._prune_http_bridge_retry_circuit_state(now_monotonic)
+ local_state = self._http_bridge_retry_circuits.get(session.key)
+ if local_state is not None:
+ local_state.last_touched_monotonic = now_monotonic
+ try:
+ persisted = await self._durable_bridge.lookup_retry_circuit(
+ session_key_kind=session.key.affinity_kind,
+ session_key_value=session.key.affinity_key,
+ api_key_id=session.key.api_key_id,
+ )
+ except Exception:
+ if PROMETHEUS_AVAILABLE and http_bridge_retry_circuit_total is not None:
+ http_bridge_retry_circuit_total.labels(outcome="lookup_failed").inc()
+ logger.warning(
+ "Failed to load persisted HTTP bridge retry circuit bridge_kind=%s bridge_key=%s",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ exc_info=True,
+ )
+ return False
+
+ if persisted is None:
+ # A durable miss clears state loaded from another replica, but it
+ # must not discard a failure recorded locally after the last
+ # durable read. That local circuit is the only protection against
+ # immediately replaying the same failing upstream request.
+ async with self._http_bridge_retry_circuit_lock:
+ local_state = self._http_bridge_retry_circuits.get(session.key)
+ locally_updated = bool(
+ local_state is not None
+ and local_state.last_failure_monotonic > local_state.last_durable_load_monotonic
+ )
+ if session.key in self._http_bridge_retry_circuit_persisted_keys and not locally_updated:
+ self._http_bridge_retry_circuits.pop(session.key, None)
+ self._http_bridge_retry_circuit_loaded_keys.discard(session.key)
+ self._http_bridge_retry_circuit_persisted_keys.discard(session.key)
+ return True
+
+ now_epoch = time.time()
+ if now_epoch - persisted.updated_at_epoch > DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS:
+ async with self._http_bridge_retry_circuit_lock:
+ stale_local_state = self._http_bridge_retry_circuits.get(session.key)
+ try:
+ await self._durable_bridge.purge_retry_circuit(
+ session_key_kind=session.key.affinity_kind,
+ session_key_value=session.key.affinity_key,
+ api_key_id=session.key.api_key_id,
+ expected_updated_at_epoch=persisted.updated_at_epoch,
+ )
+ except Exception:
+ logger.warning(
+ "Failed to remove stale HTTP bridge retry circuit bridge_kind=%s bridge_key=%s",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ exc_info=True,
+ )
+ # Keep a newer process-local circuit when persistence is
+ # unavailable. The next failure can still open the local
+ # circuit even though the expired durable row remains.
+ return False
+ async with self._http_bridge_retry_circuit_lock:
+ current_local_state = self._http_bridge_retry_circuits.get(session.key)
+ local_state_is_newer = bool(
+ current_local_state is not None
+ and current_local_state.last_failure_monotonic > current_local_state.last_durable_load_monotonic
+ )
+ if current_local_state is None or (
+ current_local_state is stale_local_state and not local_state_is_newer
+ ):
+ self._http_bridge_retry_circuits.pop(session.key, None)
+ self._http_bridge_retry_circuit_loaded_keys.discard(session.key)
+ self._http_bridge_retry_circuit_persisted_keys.discard(session.key)
+ return True
+
+ cooldown_remaining = max(0.0, persisted.cooldown_until_epoch - now_epoch)
+ persisted_cooldown_until = now_monotonic + cooldown_remaining
+ async with self._http_bridge_retry_circuit_lock:
+ self._http_bridge_retry_circuit_persisted_keys.add(session.key)
+ state = self._http_bridge_retry_circuits.get(session.key)
+ if state is None:
+ state = _HTTPBridgeRetryCircuitState(last_touched_monotonic=now_monotonic)
+ self._http_bridge_retry_circuits[session.key] = state
+ local_failure_is_newer = state.last_failure_monotonic > state.last_durable_load_monotonic
+ if persisted.updated_at_epoch > state.persisted_updated_at_epoch and not local_failure_is_newer:
+ state.consecutive_failures = max(0, persisted.consecutive_failures)
+ state.cooldown_until = persisted_cooldown_until
+ state.last_detail = persisted.last_detail
+ else:
+ state.consecutive_failures = max(state.consecutive_failures, max(0, persisted.consecutive_failures))
+ state.cooldown_until = max(state.cooldown_until, persisted_cooldown_until)
+ if local_failure_is_newer:
+ state.last_detail = state.last_detail or persisted.last_detail
+ else:
+ state.last_detail = persisted.last_detail or state.last_detail
+ state.persisted_updated_at_epoch = max(state.persisted_updated_at_epoch, persisted.updated_at_epoch)
+ state.last_touched_monotonic = now_monotonic
+ state.last_durable_load_monotonic = now_monotonic
+ self._http_bridge_retry_circuit_loaded_keys.add(session.key)
+ return True
+
+ async def _persist_http_bridge_retry_circuit(
+ self: Any,
+ session: _HTTPBridgeSession,
+ state: _HTTPBridgeRetryCircuitState,
+ ) -> None:
+ now_monotonic = time.monotonic()
+ now_wall = time.time()
+ threshold = max(1, _HTTP_BRIDGE_RETRY_CIRCUIT_FAILURE_THRESHOLD)
+ async with self._http_bridge_retry_circuit_lock:
+ if self._http_bridge_retry_circuits.get(session.key) is not state:
+ return
+ consecutive_failures = state.consecutive_failures
+ cooldown_until = state.cooldown_until
+ last_detail = state.last_detail
+ persisted_updated_at_epoch = state.persisted_updated_at_epoch
+ base_backoff = max(0.001, _HTTP_BRIDGE_RETRY_CIRCUIT_BASE_BACKOFF_SECONDS)
+ if last_detail == "clean_close":
+ base_backoff = min(
+ base_backoff,
+ max(0.001, _HTTP_BRIDGE_RETRY_CIRCUIT_CLEAN_CLOSE_MAX_BACKOFF_SECONDS),
+ )
+ try:
+ persisted = await self._durable_bridge.persist_retry_circuit(
+ session_key_kind=session.key.affinity_kind,
+ session_key_value=session.key.affinity_key,
+ api_key_id=session.key.api_key_id,
+ consecutive_failures=consecutive_failures,
+ cooldown_until_epoch=now_wall + max(0.0, cooldown_until - now_monotonic),
+ last_detail=last_detail,
+ updated_at_epoch=now_wall,
+ base_updated_at_epoch=persisted_updated_at_epoch,
+ failure_threshold=threshold,
+ conflict_cooldown_until_epoch=now_wall + base_backoff,
+ base_backoff_seconds=max(0.001, _HTTP_BRIDGE_RETRY_CIRCUIT_BASE_BACKOFF_SECONDS),
+ max_backoff_seconds=max(0.001, _HTTP_BRIDGE_RETRY_CIRCUIT_MAX_BACKOFF_SECONDS),
+ clean_close_max_backoff_seconds=max(
+ 0.001,
+ _HTTP_BRIDGE_RETRY_CIRCUIT_CLEAN_CLOSE_MAX_BACKOFF_SECONDS,
+ ),
+ )
+ if persisted is not None:
+ persisted_cooldown_until = now_monotonic + max(0.0, persisted.cooldown_until_epoch - now_wall)
+ async with self._http_bridge_retry_circuit_lock:
+ current = self._http_bridge_retry_circuits.get(session.key)
+ if current is state:
+ local_failure_is_newer = state.last_failure_monotonic > state.last_durable_load_monotonic
+ if persisted.updated_at_epoch > state.persisted_updated_at_epoch and not local_failure_is_newer:
+ state.consecutive_failures = max(0, persisted.consecutive_failures)
+ state.cooldown_until = persisted_cooldown_until
+ state.last_detail = persisted.last_detail
+ else:
+ state.consecutive_failures = max(state.consecutive_failures, persisted.consecutive_failures)
+ state.cooldown_until = max(state.cooldown_until, persisted_cooldown_until)
+ if local_failure_is_newer:
+ state.last_detail = state.last_detail or persisted.last_detail
+ else:
+ state.last_detail = persisted.last_detail or state.last_detail
+ state.persisted_updated_at_epoch = max(
+ state.persisted_updated_at_epoch,
+ persisted.updated_at_epoch,
+ )
+ # This write is now the durable baseline for the
+ # captured local failure. A failure recorded while
+ # the write was in flight still has a later
+ # monotonic timestamp and will remain dominant.
+ state.last_durable_load_monotonic = max(
+ state.last_durable_load_monotonic,
+ now_monotonic,
+ )
+ async with self._http_bridge_retry_circuit_lock:
+ if self._http_bridge_retry_circuits.get(session.key) is state:
+ self._http_bridge_retry_circuit_persisted_keys.add(session.key)
+ except Exception:
+ if PROMETHEUS_AVAILABLE and http_bridge_retry_circuit_total is not None:
+ http_bridge_retry_circuit_total.labels(outcome="persist_failed").inc()
+ logger.warning(
+ "Failed to persist HTTP bridge retry circuit bridge_kind=%s bridge_key=%s",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ exc_info=True,
+ )
+
+ async def _http_bridge_precreated_retry_allowed(
+ self: Any,
+ session: _HTTPBridgeSession,
+ *,
+ allow_fresh_hard_account_switch: bool = False,
+ allow_proof_gated_continuity_replay: bool = False,
+ ) -> bool:
+ """Avoid replaying a repeatedly failing hard-affinity request in a tight loop."""
+ if session.key.strength != "hard":
+ return True
+
+ await self._load_http_bridge_retry_circuit(session)
+ now = time.monotonic()
+ async with self._http_bridge_retry_circuit_lock:
+ state = self._http_bridge_retry_circuits.get(session.key)
+ if state is None or state.cooldown_until <= now:
+ if (
+ state is not None
+ and state.consecutive_failures >= _HTTP_BRIDGE_RETRY_CIRCUIT_FAILURE_THRESHOLD
+ and state.half_open_until > now
+ and not allow_fresh_hard_account_switch
+ and not allow_proof_gated_continuity_replay
+ ):
+ if PROMETHEUS_AVAILABLE and http_bridge_retry_circuit_total is not None:
+ http_bridge_retry_circuit_total.labels(outcome="suppressed").inc()
+ return False
+ if state is not None and state.cooldown_until > 0:
+ state.cooldown_until = 0.0
+ state.half_open_until = now + _HTTP_BRIDGE_RETRY_CIRCUIT_HALF_OPEN_LEASE_SECONDS
+ logger.info(
+ "http_bridge_retry_circuit event=half_open bridge_kind=%s bridge_key=%s failures=%s",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ state.consecutive_failures,
+ )
+ return True
+
+ retry_after = max(0.0, state.cooldown_until - now)
+ if allow_fresh_hard_account_switch:
+ logger.info(
+ "http_bridge_retry_circuit event=bypass_fresh_account_switch bridge_kind=%s "
+ "bridge_key=%s failures=%s retry_after_seconds=%.1f",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ state.consecutive_failures,
+ retry_after,
+ )
+ return True
+ if allow_proof_gated_continuity_replay:
+ logger.info(
+ "http_bridge_retry_circuit event=bypass_proof_gated_continuity_replay bridge_kind=%s "
+ "bridge_key=%s failures=%s retry_after_seconds=%.1f",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ state.consecutive_failures,
+ retry_after,
+ )
+ return True
+ if PROMETHEUS_AVAILABLE and http_bridge_retry_circuit_total is not None:
+ http_bridge_retry_circuit_total.labels(outcome="suppressed").inc()
+ logger.info(
+ "http_bridge_retry_circuit event=suppressed bridge_kind=%s bridge_key=%s "
+ "failures=%s retry_after_seconds=%.1f detail=%s",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ state.consecutive_failures,
+ retry_after,
+ state.last_detail,
+ )
+ return False
+
+ async def _http_bridge_precreated_retry_cooldown_seconds(self: Any, session: _HTTPBridgeSession) -> float:
+ if session.key.strength != "hard":
+ return 0.0
+
+ await self._load_http_bridge_retry_circuit(session)
+ now = time.monotonic()
+ async with self._http_bridge_retry_circuit_lock:
+ state = self._http_bridge_retry_circuits.get(session.key)
+ if state is None:
+ return 0.0
+ return max(0.0, state.cooldown_until - now)
+
+ async def _record_http_bridge_retry_circuit_failure(
+ self: Any,
+ session: _HTTPBridgeSession,
+ *,
+ detail: str,
+ ) -> None:
+ detail = _HTTP_BRIDGE_RETRY_CIRCUIT_DETAIL_ALIASES.get(detail, detail)
+ if session.key.strength != "hard" or detail not in _HTTP_BRIDGE_RETRY_CIRCUIT_FAILURE_DETAILS:
+ return
+
+ await self._load_http_bridge_retry_circuit(session)
+ threshold = max(1, _HTTP_BRIDGE_RETRY_CIRCUIT_FAILURE_THRESHOLD)
+ base_backoff = max(0.001, _HTTP_BRIDGE_RETRY_CIRCUIT_BASE_BACKOFF_SECONDS)
+ max_backoff = max(base_backoff, _HTTP_BRIDGE_RETRY_CIRCUIT_MAX_BACKOFF_SECONDS)
+ clean_close_max_backoff = max(0.001, _HTTP_BRIDGE_RETRY_CIRCUIT_CLEAN_CLOSE_MAX_BACKOFF_SECONDS)
+ now = time.monotonic()
+ async with self._http_bridge_retry_circuit_lock:
+ state = self._http_bridge_retry_circuits.setdefault(
+ session.key,
+ _HTTPBridgeRetryCircuitState(last_touched_monotonic=now),
+ )
+ state.last_touched_monotonic = now
+ state.last_failure_monotonic = now
+ state.half_open_until = 0.0
+ state.consecutive_failures += 1
+ state.last_detail = detail
+ if state.consecutive_failures >= threshold:
+ backoff = min(
+ max_backoff,
+ base_backoff * (2 ** min(state.consecutive_failures - threshold, 30)),
+ )
+ if detail == "clean_close":
+ backoff = min(backoff, clean_close_max_backoff)
+ state.cooldown_until = max(state.cooldown_until, now + backoff)
+ if PROMETHEUS_AVAILABLE and http_bridge_retry_circuit_total is not None:
+ http_bridge_retry_circuit_total.labels(outcome="opened").inc()
+ logger.warning(
+ "http_bridge_retry_circuit event=opened bridge_kind=%s bridge_key=%s "
+ "failures=%s cooldown_seconds=%.1f detail=%s",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ state.consecutive_failures,
+ backoff,
+ detail,
+ )
+ await self._persist_http_bridge_retry_circuit(session, state)
+ async with self._http_bridge_retry_circuit_lock:
+ if self._http_bridge_retry_circuits.get(session.key) is state:
+ self._http_bridge_retry_circuit_loaded_keys.add(session.key)
+
+ async def _clear_http_bridge_retry_circuit(self: Any, session: _HTTPBridgeSession) -> None:
+ if session.key.strength != "hard":
+ return
+
+ durable_load_succeeded = await self._load_http_bridge_retry_circuit(session)
+ async with self._http_bridge_retry_circuit_lock:
+ state = self._http_bridge_retry_circuits.pop(session.key, None)
+ self._http_bridge_retry_circuit_loaded_keys.discard(session.key)
+ self._http_bridge_retry_circuit_persisted_keys.discard(session.key)
+ expected_updated_at_epoch = (
+ state.persisted_updated_at_epoch if state is not None and state.persisted_updated_at_epoch > 0 else None
+ )
+ # A confirmed miss has no version fence to protect a row created
+ # concurrently, so leave the durable row untouched when no state was
+ # observed. Preserve the existing best-effort clear on read failures,
+ # which is still useful for settling a row after a transient outage.
+ if durable_load_succeeded and (state is None or expected_updated_at_epoch is None):
+ return
+ try:
+ # Clearing is idempotent and must be attempted even when the
+ # preceding lookup failed; a successful request should settle
+ # a previously persisted circuit after a transient read error.
+ await self._durable_bridge.clear_retry_circuit(
+ session_key_kind=session.key.affinity_kind,
+ session_key_value=session.key.affinity_key,
+ api_key_id=session.key.api_key_id,
+ expected_updated_at_epoch=expected_updated_at_epoch,
+ )
+ except Exception:
+ logger.warning(
+ "Failed to clear persisted HTTP bridge retry circuit bridge_kind=%s bridge_key=%s",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ exc_info=True,
+ )
+ if state is None:
+ return
+ if PROMETHEUS_AVAILABLE and http_bridge_retry_circuit_total is not None:
+ http_bridge_retry_circuit_total.labels(outcome="reset").inc()
+ logger.info(
+ "http_bridge_retry_circuit event=reset bridge_kind=%s bridge_key=%s failures=%s",
+ session.key.affinity_kind,
+ _hash_identifier(session.key.affinity_key),
+ state.consecutive_failures,
+ )
diff --git a/app/modules/proxy/_service/http_bridge/service_stubs.py b/app/modules/proxy/_service/http_bridge/service_stubs.py
index 93ab9113d4..b4f7ac4a27 100644
--- a/app/modules/proxy/_service/http_bridge/service_stubs.py
+++ b/app/modules/proxy/_service/http_bridge/service_stubs.py
@@ -84,7 +84,8 @@ def _service_inline_input_image_urls() -> Any:
def _stream_keepalive_max_count() -> int:
- return int(_service_global_or("_STREAM_KEEPALIVE_MAX_COUNT", _STREAM_KEEPALIVE_MAX_COUNT))
+ service_override = int(_service_global_or("_STREAM_KEEPALIVE_MAX_COUNT", _STREAM_KEEPALIVE_MAX_COUNT))
+ return max(1, service_override)
def _prewarm_response_timeout_seconds() -> float:
diff --git a/app/modules/proxy/_service/http_bridge/streaming.py b/app/modules/proxy/_service/http_bridge/streaming.py
index 1fce2706ea..0a484f900e 100644
--- a/app/modules/proxy/_service/http_bridge/streaming.py
+++ b/app/modules/proxy/_service/http_bridge/streaming.py
@@ -34,6 +34,7 @@
from app.core.clients.proxy import codex_control_request as core_codex_control_request # noqa: F401
from app.core.clients.proxy import compact_responses as core_compact_responses # noqa: F401
from app.core.clients.proxy import transcribe_audio as core_transcribe_audio # noqa: F401
+from app.core.clients.proxy_websocket import UpstreamWebSocketTransportError
from app.core.errors import (
openai_error,
response_failed_event,
@@ -41,6 +42,9 @@
from app.core.metrics.prometheus import (
PROMETHEUS_AVAILABLE,
bridge_durable_recover_total,
+ http_bridge_retry_circuit_total,
+ stream_idle_timeout_total,
+ stream_keepalive_sent_total,
)
from app.core.openai.requests import (
ResponsesRequest,
@@ -48,7 +52,9 @@
from app.core.types import JsonValue
from app.core.utils.request_id import ensure_request_id, ensure_request_scope_id
from app.core.utils.sse import format_sse_event, parse_sse_data_json
+from app.core.utils.time import utcnow
from app.db.models import (
+ HttpBridgeSessionState,
StickySessionKind,
)
from app.modules.api_keys.service import (
@@ -66,6 +72,7 @@
)
from app.modules.proxy._service.http_bridge.helpers import (
_effective_http_bridge_idle_ttl_seconds,
+ _http_bridge_durable_lease_ttl_seconds,
_http_bridge_durable_lookup_allows_turn_state_takeover,
_http_bridge_is_context_overflow_error,
_http_bridge_is_previous_response_owner_unavailable,
@@ -204,6 +211,7 @@
without_http_bridge_session_affinity_headers,
)
from app.modules.proxy.durable_bridge_coordinator import DurableBridgeLookup
+from app.modules.proxy.durable_bridge_repository import durable_bridge_hash
from app.modules.proxy.helpers import (
_normalize_error_code,
)
@@ -220,10 +228,32 @@
_RESPONSE_CREATE_GATE_RETRY_SLEEP_SECONDS = 10.0
+def _http_bridge_continuity_bound_without_safe_replay(request_state: _WebSocketRequestState) -> bool:
+ """Return whether retrying would require replaying an unsafe continuation."""
+ if request_state.previous_response_id is not None:
+ return not (request_state.fresh_upstream_request_is_retry_safe and request_state.fresh_upstream_request_text)
+ return request_state.hard_continuity_anchor and not (
+ request_state.fresh_upstream_request_is_retry_safe and request_state.fresh_upstream_request_text
+ )
+
+
def _http_bridge_payload_is_account_neutral_fresh_replay(payload: ResponsesRequest) -> bool:
return responses_payload_is_account_neutral_fresh_replay(payload.to_payload())
+def _apply_http_bridge_downstream_turn_state(
+ request_state: _WebSocketRequestState,
+ *,
+ downstream_turn_state: str | None,
+ incoming_turn_state_header: str | None,
+) -> None:
+ if downstream_turn_state is None:
+ return
+ request_state.session_id = _normalize_session_id(downstream_turn_state)
+ if incoming_turn_state_header is not None or request_state.previous_response_id is not None:
+ request_state.hard_continuity_anchor = True
+
+
def _proxy_error_code_message(exc: ProxyResponseError) -> tuple[str | None, str | None]:
error = exc.payload.get("error") if isinstance(exc.payload, dict) else None
if not isinstance(error, dict):
@@ -233,6 +263,22 @@ def _proxy_error_code_message(exc: ProxyResponseError) -> tuple[str | None, str
return (str(code) if code is not None else None, str(message) if message is not None else None)
+_HTTP_BRIDGE_AMBIGUOUS_RECOVERY_ERROR_CODES = frozenset(
+ {
+ "stream_incomplete",
+ "stream_idle_timeout",
+ "upstream_request_timeout",
+ }
+)
+
+
+def _http_bridge_error_is_ambiguous_transport(exc: ProxyResponseError) -> bool:
+ """Return whether an error leaves upstream acceptance genuinely unknown."""
+
+ code, _message = _proxy_error_code_message(exc)
+ return code in _HTTP_BRIDGE_AMBIGUOUS_RECOVERY_ERROR_CODES
+
+
def _http_bridge_account_capacity_wait_seconds(exc: ProxyResponseError) -> float | None:
code, message = _proxy_error_code_message(exc)
if code == "capacity_exhausted_active_sessions":
@@ -899,6 +945,7 @@ def prepare_bridge_request(
untrimmed_effective_payload = payload
proxy_injected_previous_response_id = False
fresh_upstream_request_text: str | None = None
+ client_full_resend_fresh_upstream_request_text: str | None = None
previous_response_trimmed_input_count: int | None = None
previous_response_trimmed_input_fingerprint: str | None = None
durable_full_resend_anchor_count: int | None = None
@@ -907,6 +954,11 @@ def prepare_bridge_request(
durable_full_resend_is_account_neutral: bool | None = None
durable_full_resend_has_safe_fresh_context = False
durable_full_resend_retains_prior_output = False
+ durable_recovery_attempt_fingerprint: str | None = None
+ durable_recovery_attempt_available = False
+ durable_recovery_attempt_claimed = False
+ durable_recovery_attempt_session_id: str | None = None
+ durable_recovery_attempt_owner_epoch: int | None = None
force_local_recovery_creation = False
payload_looks_like_full_resend = _http_bridge_payload_looks_like_full_resend(payload)
@@ -950,6 +1002,109 @@ def classify_durable_full_resend(
durable_full_resend_anchor_fingerprint,
durable_full_resend_has_safe_fresh_context,
) = classify_durable_full_resend(durable_lookup)
+ if (
+ durable_full_resend_has_safe_fresh_context
+ and durable_full_resend_anchor_count is not None
+ and isinstance(payload.input, list)
+ ):
+ replay_projection = project_responses_input_for_account_neutral_fresh_replay(
+ cast(list[JsonValue], payload.input),
+ stored_count=durable_full_resend_anchor_count,
+ )
+ if replay_projection is not None:
+ durable_full_resend_retains_prior_output = responses_input_suffix_retains_prior_output(
+ replay_projection.input_items,
+ stored_count=replay_projection.stored_prefix_count,
+ )
+ durable_full_resend_fresh_payload = _http_bridge_payload_without_previous_response_id(
+ payload
+ ).model_copy(update={"input": replay_projection.input_items})
+ durable_full_resend_is_account_neutral = _http_bridge_payload_is_account_neutral_fresh_replay(
+ durable_full_resend_fresh_payload
+ )
+ _fresh_state, fresh_replay_text = prepare_bridge_request(
+ _http_bridge_payload_without_previous_response_id(payload)
+ )
+ del _fresh_state
+ durable_recovery_attempt_fingerprint = durable_bridge_hash(fresh_replay_text)
+ if durable_lookup is not None and durable_full_resend_is_account_neutral:
+ try:
+ existing_attempt = await self._durable_bridge.lookup_recovery_attempt(
+ session_id=durable_lookup.session_id,
+ request_fingerprint=durable_recovery_attempt_fingerprint,
+ )
+ if existing_attempt is not None and (
+ durable_lookup.state != HttpBridgeSessionState.ACTIVE
+ or not durable_lookup.lease_is_active(now=utcnow())
+ ):
+ claim_instance_id = _service_get_settings().http_responses_session_bridge_instance_id
+ claim_owner_epoch = durable_lookup.owner_epoch
+ owner_is_current = (
+ durable_lookup.owner_instance_id == claim_instance_id
+ and durable_lookup.lease_is_active(now=utcnow())
+ )
+ if not owner_is_current:
+ claimed_session = await self._durable_bridge.claim_live_session(
+ session_key_kind=durable_lookup.canonical_kind,
+ session_key_value=durable_lookup.canonical_key,
+ api_key_id=bridge_session_key.api_key_id,
+ instance_id=claim_instance_id,
+ lease_ttl_seconds=_http_bridge_durable_lease_ttl_seconds(),
+ account_id=durable_lookup.account_id,
+ model=payload.model,
+ service_tier=None,
+ latest_turn_state=durable_lookup.latest_turn_state,
+ latest_response_id=None,
+ # Revalidate the stale lookup under the
+ # row lock; an active owner that appeared
+ # after the lookup must not be displaced.
+ allow_takeover=False,
+ )
+ if claimed_session.owner_instance_id != claim_instance_id:
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses recovery ownership changed; retry the request.",
+ ),
+ )
+ claim_owner_epoch = claimed_session.owner_epoch
+ claimed = await self._durable_bridge.mark_recovery_attempt_replayed(
+ session_id=durable_lookup.session_id,
+ api_key_id=bridge_session_key.api_key_id,
+ instance_id=claim_instance_id,
+ owner_epoch=claim_owner_epoch,
+ request_fingerprint=durable_recovery_attempt_fingerprint,
+ )
+ if not claimed:
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses recovery ownership changed; retry the request.",
+ ),
+ )
+ durable_recovery_attempt_claimed = True
+ durable_recovery_attempt_available = False
+ durable_recovery_attempt_session_id = durable_lookup.session_id
+ durable_recovery_attempt_owner_epoch = claim_owner_epoch
+ elif existing_attempt is None:
+ # No prior attempt owns this fingerprint. The
+ # request-submit path will journal it immediately
+ # before dispatch, and an ambiguous transport
+ # outcome may then consume the one replay fence.
+ durable_recovery_attempt_available = True
+ except ProxyResponseError:
+ raise
+ except Exception:
+ logger.warning("Failed to claim HTTP bridge recovery attempt", exc_info=True)
+ raise ProxyResponseError(
+ 502,
+ openai_error(
+ "bridge_continuity_persistence_failed",
+ "HTTP responses recovery state could not be claimed; retry the request.",
+ ),
+ )
durable_anchor_trimmable = durable_full_resend_anchor_count is not None
durable_model_transition_lookup = (
durable_lookup
@@ -1065,8 +1220,11 @@ def classify_durable_full_resend(
request_state, text_data = prepare_bridge_request(effective_payload)
request_state.enforce_openai_sdk_contract = enforce_openai_sdk_contract
request_state.affinity_policy = affinity
- if downstream_turn_state is not None:
- request_state.session_id = _normalize_session_id(downstream_turn_state)
+ _apply_http_bridge_downstream_turn_state(
+ request_state,
+ downstream_turn_state=downstream_turn_state,
+ incoming_turn_state_header=incoming_turn_state_header,
+ )
if previous_response_trimmed_input_count is not None:
request_state.input_item_count = previous_response_trimmed_input_count
request_state.input_full_fingerprint = previous_response_trimmed_input_fingerprint
@@ -1176,6 +1334,24 @@ def classify_durable_full_resend(
# Only the trim branch below (which verifies the stored prefix
# fingerprint) is allowed to flip this flag to ``True``.
request_state.fresh_upstream_request_is_retry_safe = False
+ elif (
+ effective_payload.previous_response_id is not None
+ and payload_looks_like_full_resend
+ and durable_full_resend_anchor_count is not None
+ and durable_full_resend_has_safe_fresh_context
+ ):
+ # A client-provided full resend carries the same proof as a
+ # proxy-injected anchor: the stored prefix matches and the fresh
+ # suffix retains the prior output/tool context. Capture the
+ # verified anchor-free body so a retry can use it without sending
+ # previous_response_id again.
+ client_full_resend_payload = _http_bridge_payload_without_previous_response_id(untrimmed_effective_payload)
+ _fresh_state, client_full_resend_fresh_upstream_request_text = prepare_bridge_request(
+ client_full_resend_payload
+ )
+ del _fresh_state
+ request_state.fresh_upstream_request_text = client_full_resend_fresh_upstream_request_text
+ request_state.fresh_upstream_request_is_retry_safe = True
settings = _service_get_settings()
request_deadline = request_state.started_at + _http_bridge_request_budget_seconds(settings)
session_creation_headers = (
@@ -1233,6 +1409,7 @@ def switch_to_account_neutral_replay() -> None:
nonlocal effective_payload
nonlocal file_required_preferred_account
nonlocal force_local_recovery_creation
+ nonlocal client_full_resend_fresh_upstream_request_text
nonlocal fresh_upstream_request_text
nonlocal incoming_turn_state_header
nonlocal previous_response_trimmed_input_count
@@ -1284,6 +1461,7 @@ def switch_to_account_neutral_replay() -> None:
untrimmed_effective_payload = fresh_payload
proxy_injected_previous_response_id = False
fresh_upstream_request_text = None
+ client_full_resend_fresh_upstream_request_text = None
previous_response_trimmed_input_count = None
previous_response_trimmed_input_fingerprint = None
durable_full_resend_anchor_count = None
@@ -1293,6 +1471,13 @@ def switch_to_account_neutral_replay() -> None:
durable_lookup = None
file_required_preferred_account = False
+ if durable_recovery_attempt_claimed:
+ switch_to_account_neutral_replay()
+ request_state.recovery_attempt_fingerprint = durable_recovery_attempt_fingerprint
+ request_state.recovery_attempt_session_id = durable_recovery_attempt_session_id
+ request_state.recovery_attempt_owner_epoch = durable_recovery_attempt_owner_epoch
+ request_state.recovery_attempt_claimed = True
+
if required_continuity_owner_missing:
owner_unavailable = ProxyResponseError(
502,
@@ -1394,6 +1579,15 @@ def switch_to_account_neutral_replay() -> None:
raise
continue
raise
+ _log_http_bridge_event(
+ "owner_unavailable_fresh_resend",
+ bridge_session_key,
+ account_id=request_state.preferred_account_id,
+ model=payload.model,
+ detail="outcome=fresh_full_resend_without_anchor",
+ cache_key_family=bridge_session_key.affinity_kind,
+ model_class=_extract_model_class(payload.model) if payload.model else None,
+ )
switch_to_account_neutral_replay()
continue
break
@@ -1736,8 +1930,11 @@ def switch_to_account_neutral_replay() -> None:
)
retry_request_state.enforce_openai_sdk_contract = enforce_openai_sdk_contract
retry_request_state.affinity_policy = affinity
- if downstream_turn_state is not None:
- retry_request_state.session_id = _normalize_session_id(downstream_turn_state)
+ _apply_http_bridge_downstream_turn_state(
+ retry_request_state,
+ downstream_turn_state=downstream_turn_state,
+ incoming_turn_state_header=incoming_turn_state_header,
+ )
retry_request_state.transport = _REQUEST_TRANSPORT_HTTP
retry_request_state.request_stage = (
request_state.request_stage if owner_forward_fresh_replay else "reattach"
@@ -1916,8 +2113,11 @@ def switch_to_account_neutral_replay() -> None:
request_state, text_data = prepare_bridge_request(submit_payload)
request_state.enforce_openai_sdk_contract = enforce_openai_sdk_contract
request_state.affinity_policy = affinity
- if downstream_turn_state is not None:
- request_state.session_id = _normalize_session_id(downstream_turn_state)
+ _apply_http_bridge_downstream_turn_state(
+ request_state,
+ downstream_turn_state=downstream_turn_state,
+ incoming_turn_state_header=incoming_turn_state_header,
+ )
request_state.transport = _REQUEST_TRANSPORT_HTTP
request_state.request_stage = _http_bridge_request_stage(
headers=headers,
@@ -1950,6 +2150,9 @@ def switch_to_account_neutral_replay() -> None:
if store_context_trim_applied
else previous_request_state.fresh_upstream_request_is_retry_safe
)
+ elif client_full_resend_fresh_upstream_request_text is not None:
+ request_state.fresh_upstream_request_text = client_full_resend_fresh_upstream_request_text
+ request_state.fresh_upstream_request_is_retry_safe = True
initial_handoff_session = session
initial_handoff_scope_id = ensure_request_scope_id() if original_request_unanchored else None
if initial_handoff_scope_id is not None:
@@ -1974,6 +2177,29 @@ def switch_to_account_neutral_replay() -> None:
)
try:
yielded_any = False
+ durable_recovery_fresh_replay = False
+ retry_request_state: _WebSocketRequestState | None = None
+
+ async def rollback_pre_dispatch_recovery_claim() -> None:
+ if not (
+ durable_recovery_fresh_replay
+ and (retry_request_state is None or not retry_request_state.recovery_attempt_dispatched)
+ and durable_recovery_attempt_fingerprint is not None
+ and durable_recovery_attempt_session_id is not None
+ and durable_recovery_attempt_owner_epoch is not None
+ ):
+ return
+ try:
+ await self._durable_bridge.rollback_recovery_attempt_replayed(
+ session_id=durable_recovery_attempt_session_id,
+ api_key_id=bridge_session_key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=durable_recovery_attempt_owner_epoch,
+ request_fingerprint=durable_recovery_attempt_fingerprint,
+ )
+ except Exception:
+ logger.warning("Failed to roll back pre-dispatch HTTP bridge recovery claim", exc_info=True)
+
async for event_block in session_events:
yield event_block
yielded_any = True
@@ -2192,6 +2418,62 @@ def switch_to_account_neutral_replay() -> None:
except Exception:
pass
return
+ if (
+ durable_recovery_attempt_available
+ and durable_recovery_attempt_fingerprint is not None
+ and _http_bridge_error_is_ambiguous_transport(exc)
+ and request_state.response_event_count == 0
+ and request_state.previous_response_id is not None
+ and session.durable_session_id is not None
+ and session.durable_owner_epoch is not None
+ ):
+ try:
+ marked = await self._durable_bridge.mark_recovery_attempt_replayed(
+ session_id=session.durable_session_id,
+ api_key_id=bridge_session_key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=session.durable_owner_epoch,
+ request_fingerprint=durable_recovery_attempt_fingerprint,
+ )
+ except Exception:
+ marked = False
+ logger.warning("Failed to fence HTTP bridge recovery attempt", exc_info=True)
+ if marked:
+ durable_recovery_fresh_replay = True
+ recovery_origin_session_id = request_state.recovery_attempt_session_id or session.durable_session_id
+ recovery_origin_owner_epoch = (
+ request_state.recovery_attempt_owner_epoch or session.durable_owner_epoch
+ )
+ durable_recovery_attempt_session_id = recovery_origin_session_id
+ durable_recovery_attempt_owner_epoch = recovery_origin_owner_epoch
+ _log_http_bridge_event(
+ "durable_recovery_fresh_replay",
+ bridge_session_key,
+ account_id=session.account.id,
+ model=effective_payload.model,
+ detail="outcome=new_account_neutral_upstream_session",
+ cache_key_family=bridge_session_key.affinity_kind,
+ model_class=_extract_model_class(effective_payload.model) if effective_payload.model else None,
+ owner_check_applied=True,
+ )
+ await self._reset_http_bridge_session_after_local_terminal_error(
+ session,
+ error_code="stream_incomplete",
+ error_message="Upstream websocket closed before response.completed",
+ preserve_durable_lease=True,
+ )
+ switch_to_account_neutral_replay()
+ request_state.recovery_attempt_fingerprint = durable_recovery_attempt_fingerprint
+ request_state.recovery_attempt_session_id = recovery_origin_session_id
+ request_state.recovery_attempt_owner_epoch = recovery_origin_owner_epoch
+ recovery_path = "durable_recovery_fresh_replay"
+ retry_payload = effective_payload
+ retry_previous_response_id = None
+ retry_request_stage = "durable_recovery"
+ retry_preferred_account_id = None
+ allow_previous_response_recovery_rebind = False
+ else:
+ durable_recovery_attempt_available = False
is_context_overflow = _http_bridge_is_context_overflow_error(exc)
should_rollover_after_context_overflow = _http_bridge_should_rollover_after_context_overflow(
exc,
@@ -2210,6 +2492,7 @@ def switch_to_account_neutral_replay() -> None:
not should_attempt_previous_response_recovery
and not should_rollover_after_context_overflow
and not should_attempt_context_overflow_fresh_turn_recovery
+ and not durable_recovery_fresh_replay
):
if is_context_overflow:
_log_http_bridge_event(
@@ -2224,7 +2507,9 @@ def switch_to_account_neutral_replay() -> None:
)
raise
- if should_attempt_context_overflow_fresh_turn_recovery:
+ if durable_recovery_fresh_replay:
+ pass
+ elif should_attempt_context_overflow_fresh_turn_recovery:
if PROMETHEUS_AVAILABLE and bridge_durable_recover_total is not None:
bridge_durable_recover_total.labels(path="context_overflow_fresh_turn").inc()
_log_http_bridge_event(
@@ -2390,8 +2675,16 @@ def switch_to_account_neutral_replay() -> None:
reservation=retry_api_key_reservation,
)
retry_request_state.enforce_openai_sdk_contract = enforce_openai_sdk_contract
- if downstream_turn_state is not None:
- retry_request_state.session_id = _normalize_session_id(downstream_turn_state)
+ if durable_recovery_fresh_replay and durable_recovery_attempt_fingerprint is not None:
+ retry_request_state.recovery_attempt_fingerprint = durable_recovery_attempt_fingerprint
+ retry_request_state.recovery_attempt_session_id = request_state.recovery_attempt_session_id
+ retry_request_state.recovery_attempt_owner_epoch = request_state.recovery_attempt_owner_epoch
+ retry_request_state.recovery_attempt_claimed = True
+ _apply_http_bridge_downstream_turn_state(
+ retry_request_state,
+ downstream_turn_state=downstream_turn_state,
+ incoming_turn_state_header=incoming_turn_state_header,
+ )
retry_request_state.transport = _REQUEST_TRANSPORT_HTTP
retry_request_state.request_stage = retry_request_stage
retry_request_state.preferred_account_id = retry_preferred_account_id
@@ -2415,6 +2708,7 @@ def switch_to_account_neutral_replay() -> None:
except Exception:
pass
except BaseException:
+ await rollback_pre_dispatch_recovery_claim()
if retry_reservation_reacquired and retry_api_key_reservation is not None:
await self._release_websocket_reservation(retry_api_key_reservation)
raise
@@ -2441,6 +2735,7 @@ async def _reset_http_bridge_session_after_local_terminal_error(
*,
error_code: str,
error_message: str,
+ preserve_durable_lease: bool = False,
) -> None:
async with self._http_bridge_lock:
if self._http_bridge_sessions.get(session.key) is session:
@@ -2457,7 +2752,7 @@ async def _reset_http_bridge_session_after_local_terminal_error(
api_key=None,
response_create_gate=session.response_create_gate,
)
- await self._close_http_bridge_session(session)
+ await self._close_http_bridge_session(session, release_durable_session=not preserve_durable_lease)
async def _stream_http_bridge_session_events(
self: Any,
@@ -2478,7 +2773,128 @@ async def _stream_http_bridge_session_events(
key=session.key.affinity_key,
)
request_state.propagate_http_errors = propagate_http_errors
+
+ async def retry_precreated_for_idle_recovery(
+ *,
+ downstream_response_id: str,
+ after_circuit_cooldown: bool = False,
+ ) -> tuple[bool, str | None]:
+ # The reader may already be performing the bounded additional
+ # clean-close replay (including its jitter). Wait for that result
+ # instead of interpreting the in-progress flag as a terminal idle
+ # failure and detaching the request underneath the replay.
+ if request_state.clean_close_retry_in_progress:
+ while request_state.clean_close_retry_in_progress:
+ if _service_time().monotonic() >= request_deadline:
+ return (
+ False,
+ format_sse_event(
+ cast(
+ Mapping[str, JsonValue],
+ response_failed_event(
+ "stream_idle_timeout",
+ "Clean-close recovery exceeded the request budget",
+ response_id=downstream_response_id,
+ ),
+ )
+ ),
+ )
+ await asyncio.sleep(0.01)
+ if request_state.clean_close_retry_result is True:
+ return True, None
+ try:
+ return (
+ await self._retry_http_bridge_precreated_request(
+ session,
+ restart_reader=True,
+ ),
+ None,
+ )
+ except UpstreamWebSocketTransportError as exc:
+ if PROMETHEUS_AVAILABLE and stream_idle_timeout_total is not None:
+ stream_idle_timeout_total.labels(surface="http_bridge").inc()
+ logger.info(
+ "HTTP bridge stream idle recovery retry%s failed with transport error request_id=%s error_code=%s",
+ " after circuit cooldown" if after_circuit_cooldown else "",
+ request_state.request_id,
+ exc.error_code,
+ )
+ return (
+ False,
+ format_sse_event(
+ cast(
+ Mapping[str, JsonValue],
+ response_failed_event(
+ exc.error_code,
+ str(exc),
+ response_id=downstream_response_id,
+ ),
+ )
+ ),
+ )
+
+ def continuity_bound_without_safe_replay() -> bool:
+ """Do not hold a client stream through a cooldown we cannot use."""
+ return _http_bridge_continuity_bound_without_safe_replay(request_state)
+
+ async def startup_continuity_cooldown_terminal_event() -> str | None:
+ if (
+ session.key.strength != "hard"
+ or not continuity_bound_without_safe_replay()
+ or request_state.response_id is not None
+ or request_state.response_event_count > 0
+ ):
+ return None
+ retry_cooldown_seconds = await self._http_bridge_precreated_retry_cooldown_seconds(session)
+ if retry_cooldown_seconds <= 0:
+ return None
+ if PROMETHEUS_AVAILABLE and stream_idle_timeout_total is not None:
+ stream_idle_timeout_total.labels(surface="http_bridge").inc()
+ _record_continuity_fail_closed(
+ surface="http_bridge",
+ reason="retry_circuit_cooldown_continuity_bound",
+ previous_response_id=request_state.previous_response_id,
+ session_id=downstream_turn_state or request_state.session_id,
+ )
+ logger.info(
+ "HTTP bridge stream idle timeout fail-closed before submit without safe replay "
+ "request_id=%s retry_after_seconds=%.1f",
+ request_state.request_id,
+ retry_cooldown_seconds,
+ )
+ # This path returns before the request is submitted, so the normal
+ # detach/finally cleanup cannot settle an API-key reservation.
+ # Release it before handing the synthetic terminal event to the
+ # non-streaming collector.
+ await self._release_websocket_request_state_reservation(request_state)
+ request_state.api_key_reservation = None
+ if propagate_http_errors:
+ raise ProxyResponseError(
+ 503,
+ openai_error(
+ "upstream_request_timeout",
+ "HTTP responses session bridge is cooling down after repeated upstream "
+ "timeouts; retry shortly.",
+ error_type="server_error",
+ ),
+ retry_after_seconds=max(1, math.ceil(retry_cooldown_seconds)),
+ )
+ return format_sse_event(
+ cast(
+ Mapping[str, JsonValue],
+ response_failed_event(
+ "stream_idle_timeout",
+ "Upstream did not respond within the keepalive window",
+ response_id=_websocket_downstream_response_id(request_state),
+ ),
+ )
+ )
+
while True:
+ startup_terminal_event = await startup_continuity_cooldown_terminal_event()
+ if startup_terminal_event is not None:
+ yield startup_terminal_event
+ return
try:
if account_neutral_recovery:
await self._submit_http_bridge_request(
@@ -2554,6 +2970,59 @@ async def _stream_http_bridge_session_events(
raise
continue
break
+ event_queue = request_state.event_queue
+ assert event_queue is not None
+ initial_retry_cooldown_seconds = await self._http_bridge_precreated_retry_cooldown_seconds(session)
+ if (
+ initial_retry_cooldown_seconds > 0
+ and session.key.strength == "hard"
+ and continuity_bound_without_safe_replay()
+ and request_state.response_id is None
+ and request_state.response_event_count == 0
+ and event_queue.empty()
+ ):
+ if PROMETHEUS_AVAILABLE and stream_idle_timeout_total is not None:
+ stream_idle_timeout_total.labels(surface="http_bridge").inc()
+ _record_continuity_fail_closed(
+ surface="http_bridge",
+ reason="retry_circuit_cooldown_continuity_bound",
+ previous_response_id=request_state.previous_response_id,
+ session_id=downstream_turn_state or request_state.session_id,
+ )
+ logger.info(
+ "HTTP bridge stream idle timeout fail-closed at startup without safe replay "
+ "request_id=%s retry_after_seconds=%.1f",
+ request_state.request_id,
+ initial_retry_cooldown_seconds,
+ )
+ terminal_event = format_sse_event(
+ cast(
+ Mapping[str, JsonValue],
+ response_failed_event(
+ "stream_idle_timeout",
+ "Upstream did not respond within the keepalive window",
+ response_id=_websocket_downstream_response_id(request_state),
+ ),
+ )
+ )
+ # The request was submitted before the durable cooldown refresh,
+ # so detach it before returning. This releases the response-create
+ # gate, reservation, and pending queue entry while marking the
+ # upstream handoff for retirement.
+ await self._detach_http_bridge_request(session, request_state=request_state)
+ if propagate_http_errors:
+ raise ProxyResponseError(
+ 503,
+ openai_error(
+ "upstream_request_timeout",
+ "HTTP responses session bridge is cooling down after repeated upstream "
+ "timeouts; retry shortly.",
+ error_type="server_error",
+ ),
+ retry_after_seconds=max(1, math.ceil(initial_retry_cooldown_seconds)),
+ )
+ yield terminal_event
+ return
try:
if downstream_turn_state is not None and not account_neutral_recovery:
await self._register_http_bridge_turn_state(session, downstream_turn_state)
@@ -2567,6 +3036,8 @@ async def _stream_http_bridge_session_events(
yielded_any = False
keepalive_sent = False
keepalive_count = 0
+ circuit_keepalive_waiting = False
+ circuit_keepalive_until: float | None = None
while True:
keepalive_interval = getattr(_service_get_settings(), "sse_keepalive_interval_seconds", 10.0)
if keepalive_interval > 0:
@@ -2577,11 +3048,31 @@ async def _stream_http_bridge_session_events(
"stream_idle_timeout_seconds",
keepalive_interval * stream_keepalive_max_count,
)
- max_keepalive_count = max(
- stream_keepalive_max_count,
- math.ceil(max(0.001, stream_idle_timeout_seconds) / keepalive_interval),
+ response_started = bool(
+ request_state.response_id
+ or request_state.replay_downstream_response_id
+ or request_state.response_event_count > 0
+ or request_state.latency_response_created_ms is not None
+ )
+ max_keepalive_count = (
+ max(
+ stream_keepalive_max_count,
+ math.ceil(max(0.001, stream_idle_timeout_seconds) / keepalive_interval),
+ )
+ if response_started
+ else stream_keepalive_max_count
)
wait_timeout = keepalive_interval
+ if circuit_keepalive_waiting:
+ # Once the circuit is cooling down, wake at the actual
+ # expiry instead of waiting through another full
+ # keepalive interval before checking it again.
+ max_keepalive_count = 1
+ if circuit_keepalive_until is not None:
+ wait_timeout = min(
+ wait_timeout,
+ max(0.001, circuit_keepalive_until - _service_time().monotonic()),
+ )
if not yielded_any and not keepalive_sent:
wait_timeout = max(wait_timeout, _http_bridge_startup_keepalive_grace_seconds())
try:
@@ -2621,29 +3112,181 @@ async def _stream_http_bridge_session_events(
continue
keepalive_count += 1
downstream_response_id = _websocket_downstream_response_id(request_state)
- if keepalive_count > max_keepalive_count:
- logger.info(
- "HTTP bridge stream idle timeout request_id=%s keepalive_count=%s "
- "max_keepalive_count=%s",
- request_state.request_id,
- keepalive_count,
- max_keepalive_count,
- )
- yield format_sse_event(
- cast(
- Mapping[str, JsonValue],
- response_failed_event(
- "stream_idle_timeout",
- "Upstream did not respond within the keepalive window",
- response_id=downstream_response_id,
- ),
+ if keepalive_count >= max_keepalive_count:
+ if not response_started:
+ retried = False
+ if not circuit_keepalive_waiting:
+ retried, terminal_event = await retry_precreated_for_idle_recovery(
+ downstream_response_id=downstream_response_id,
+ )
+ if terminal_event is not None:
+ yield terminal_event
+ break
+ if retried:
+ logger.info(
+ "HTTP bridge stream idle recovery retried pre-response request_id=%s",
+ request_state.request_id,
+ )
+ keepalive_count = 0
+ keepalive_sent = False
+ yielded_any = False
+ continue
+ retry_cooldown_seconds = await self._http_bridge_precreated_retry_cooldown_seconds(
+ session
)
- )
- break
- if propagate_http_errors and request_state.response_id is None:
+ fresh_replay_is_safe = bool(
+ request_state.fresh_upstream_request_is_retry_safe
+ and request_state.fresh_upstream_request_text
+ )
+ continuity_bound = continuity_bound_without_safe_replay()
+ if retry_cooldown_seconds > 0 and (
+ continuity_bound or (session.key.strength == "hard" and not fresh_replay_is_safe)
+ ):
+ if PROMETHEUS_AVAILABLE and stream_idle_timeout_total is not None:
+ stream_idle_timeout_total.labels(surface="http_bridge").inc()
+ _record_continuity_fail_closed(
+ surface="http_bridge",
+ reason=(
+ "retry_circuit_cooldown_continuity_bound"
+ if continuity_bound
+ else "retry_circuit_cooldown_no_safe_replay"
+ ),
+ previous_response_id=request_state.previous_response_id,
+ session_id=downstream_turn_state or request_state.session_id,
+ )
+ logger.info(
+ "HTTP bridge stream idle timeout fail-closed without safe replay "
+ "request_id=%s retry_after_seconds=%.1f continuity_bound=%s",
+ request_state.request_id,
+ retry_cooldown_seconds,
+ continuity_bound,
+ )
+ yield format_sse_event(
+ cast(
+ Mapping[str, JsonValue],
+ response_failed_event(
+ "stream_idle_timeout",
+ "Upstream did not respond within the keepalive window",
+ response_id=downstream_response_id,
+ ),
+ )
+ )
+ break
+ if retry_cooldown_seconds > 0:
+ retry_cooldown_remaining_budget = max(
+ 0.0,
+ request_deadline - _service_time().monotonic(),
+ )
+ if retry_cooldown_seconds >= retry_cooldown_remaining_budget:
+ if PROMETHEUS_AVAILABLE and stream_idle_timeout_total is not None:
+ stream_idle_timeout_total.labels(surface="http_bridge").inc()
+ logger.info(
+ "HTTP bridge stream idle timeout during retry circuit cooldown "
+ "request_id=%s retry_after_seconds=%.1f remaining_budget_seconds=%.1f",
+ request_state.request_id,
+ retry_cooldown_seconds,
+ retry_cooldown_remaining_budget,
+ )
+ yield format_sse_event(
+ cast(
+ Mapping[str, JsonValue],
+ response_failed_event(
+ "stream_idle_timeout",
+ "Upstream retry circuit cooldown exceeds the request budget",
+ response_id=downstream_response_id,
+ ),
+ )
+ )
+ break
+ circuit_keepalive_waiting = True
+ keepalive_count = 0
+ circuit_keepalive_until = _service_time().monotonic() + retry_cooldown_seconds
+ if PROMETHEUS_AVAILABLE and http_bridge_retry_circuit_total is not None:
+ http_bridge_retry_circuit_total.labels(outcome="keepalive").inc()
+ logger.info(
+ "HTTP bridge stream waiting during retry circuit cooldown "
+ "request_id=%s retry_after_seconds=%.1f",
+ request_state.request_id,
+ retry_cooldown_seconds,
+ )
+ else:
+ was_circuit_keepalive_waiting = circuit_keepalive_waiting
+ circuit_keepalive_waiting = False
+ circuit_keepalive_until = None
+ if was_circuit_keepalive_waiting and not retried:
+ retried, terminal_event = await retry_precreated_for_idle_recovery(
+ downstream_response_id=downstream_response_id,
+ after_circuit_cooldown=True,
+ )
+ if terminal_event is not None:
+ yield terminal_event
+ break
+ if retried:
+ logger.info(
+ "HTTP bridge stream idle recovery retried after circuit cooldown "
+ "request_id=%s",
+ request_state.request_id,
+ )
+ keepalive_count = 0
+ keepalive_sent = False
+ yielded_any = False
+ continue
+ if not retried:
+ await self._record_http_bridge_retry_circuit_failure(
+ session,
+ detail="stream_idle_timeout",
+ )
+ if PROMETHEUS_AVAILABLE and stream_idle_timeout_total is not None:
+ stream_idle_timeout_total.labels(surface="http_bridge").inc()
+ logger.info(
+ "HTTP bridge stream idle timeout request_id=%s keepalive_count=%s "
+ "max_keepalive_count=%s",
+ request_state.request_id,
+ keepalive_count,
+ max_keepalive_count,
+ )
+ yield format_sse_event(
+ cast(
+ Mapping[str, JsonValue],
+ response_failed_event(
+ "stream_idle_timeout",
+ "Upstream did not respond within the keepalive window",
+ response_id=downstream_response_id,
+ ),
+ )
+ )
+ break
+ elif response_started:
+ if PROMETHEUS_AVAILABLE and stream_idle_timeout_total is not None:
+ stream_idle_timeout_total.labels(surface="http_bridge").inc()
+ logger.info(
+ "HTTP bridge stream idle timeout request_id=%s keepalive_count=%s "
+ "max_keepalive_count=%s",
+ request_state.request_id,
+ keepalive_count,
+ max_keepalive_count,
+ )
+ yield format_sse_event(
+ cast(
+ Mapping[str, JsonValue],
+ response_failed_event(
+ "stream_idle_timeout",
+ "Upstream did not respond within the keepalive window",
+ response_id=downstream_response_id,
+ ),
+ )
+ )
+ break
+ if (
+ propagate_http_errors
+ and request_state.response_id is None
+ and not circuit_keepalive_waiting
+ ):
continue
keepalive_sent = True
yielded_any = True
+ if PROMETHEUS_AVAILABLE and stream_keepalive_sent_total is not None:
+ stream_keepalive_sent_total.labels(surface="http_bridge").inc()
if request_state.response_id or request_state.replay_downstream_response_id:
yield format_sse_event(
cast(
@@ -2665,6 +3308,11 @@ async def _stream_http_bridge_session_events(
if event_block is None:
break
keepalive_count = 0
+ # A real upstream event means the stream is active again; do
+ # not carry the pre-response retry-circuit wake mode into the
+ # normal response-started idle timeout policy.
+ circuit_keepalive_waiting = False
+ circuit_keepalive_until = None
block_payload = parse_sse_data_json(event_block)
block_event_type = _event_type_from_payload(None, block_payload)
if request_state.latency_first_token_ms is None:
diff --git a/app/modules/proxy/_service/http_bridge/upstream_events.py b/app/modules/proxy/_service/http_bridge/upstream_events.py
index a70367af9b..dee9812b34 100644
--- a/app/modules/proxy/_service/http_bridge/upstream_events.py
+++ b/app/modules/proxy/_service/http_bridge/upstream_events.py
@@ -46,6 +46,7 @@
)
from app.modules.proxy._service.http_bridge.helpers import (
_HTTP_BRIDGE_MISSING_RESPONSE_CREATED_TIMEOUT_DETAIL,
+ _http_bridge_durable_lease_ttl_seconds,
_http_bridge_eventless_precreated_deadline,
_http_bridge_request_budget_seconds,
_http_bridge_request_counts_against_queue,
@@ -57,6 +58,7 @@
_assign_websocket_response_id,
_await_cancelled_task,
_build_stream_incomplete_terminal_event_for_request,
+ _classify_upstream_close,
_find_websocket_request_state_by_response_id,
_http_error_status_from_payload,
_is_missing_tool_output_error,
@@ -179,6 +181,123 @@
)
logger = logging.getLogger("app.modules.proxy.service")
+
+_HTTP_BRIDGE_RECOVERY_SETTLEMENT_RETRY_DELAYS = (
+ 0.25,
+ 0.5,
+ 1.0,
+ 2.0,
+ 4.0,
+ 8.0,
+ 15.0,
+ 30.0,
+ 60.0,
+ 120.0,
+)
+_HTTP_BRIDGE_RECOVERY_SETTLEMENT_LEASE_REFRESH_INTERVAL_SECONDS = 10.0
+
+
+async def _wait_for_http_bridge_recovery_settlement_retry(
+ service: Any,
+ *,
+ session_id: str,
+ owner_epoch: int,
+ api_key_id: str | None,
+ delay_seconds: float,
+) -> None:
+ remaining = max(0.0, delay_seconds)
+ while remaining > 0:
+ await asyncio.sleep(min(remaining, _HTTP_BRIDGE_RECOVERY_SETTLEMENT_LEASE_REFRESH_INTERVAL_SECONDS))
+ remaining -= _HTTP_BRIDGE_RECOVERY_SETTLEMENT_LEASE_REFRESH_INTERVAL_SECONDS
+ try:
+ await service._durable_bridge.renew_live_session(
+ session_id=session_id,
+ api_key_id=api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=owner_epoch,
+ lease_ttl_seconds=_http_bridge_durable_lease_ttl_seconds(),
+ )
+ except Exception:
+ logger.debug("Failed to refresh HTTP bridge lease during settlement backoff", exc_info=True)
+
+
+async def _retry_http_bridge_recovery_settlement(
+ service: Any,
+ session: Any,
+ *,
+ session_id: str,
+ api_key_id: str | None,
+ instance_id: str,
+ owner_epoch: int,
+ request_fingerprint: str,
+ response_id: str | None,
+ release_origin_lease: bool,
+) -> None:
+ """Keep a response-observed journal row fenced until durable settlement succeeds."""
+
+ for delay_seconds in _HTTP_BRIDGE_RECOVERY_SETTLEMENT_RETRY_DELAYS:
+ await _wait_for_http_bridge_recovery_settlement_retry(
+ service,
+ session_id=session_id,
+ owner_epoch=owner_epoch,
+ api_key_id=api_key_id,
+ delay_seconds=delay_seconds,
+ )
+ try:
+ marked = await service._durable_bridge.mark_recovery_attempt_replayed(
+ session_id=session_id,
+ api_key_id=api_key_id,
+ instance_id=instance_id,
+ owner_epoch=owner_epoch,
+ request_fingerprint=request_fingerprint,
+ response_id=response_id,
+ )
+ if marked and (release_origin_lease or getattr(session, "closed", False)):
+ try:
+ await service._durable_bridge.release_live_session(
+ session_id=session_id,
+ instance_id=instance_id,
+ owner_epoch=owner_epoch,
+ draining=False,
+ )
+ except Exception:
+ logger.debug("Failed to release HTTP bridge recovery origin lease", exc_info=True)
+ if marked:
+ return
+ logger.warning("HTTP bridge recovery settlement owner fence rejected; retrying")
+ except Exception:
+ continue
+ logger.error(
+ "HTTP bridge recovery settlement retry budget exhausted session_id=%s fingerprint=%s",
+ _hash_identifier(session_id),
+ _hash_identifier(request_fingerprint),
+ )
+
+
+def _schedule_http_bridge_recovery_settlement_retry(
+ service: Any,
+ session: Any,
+ **kwargs: Any,
+) -> None:
+ task = asyncio.create_task(
+ _retry_http_bridge_recovery_settlement(service, session, **kwargs),
+ name=f"http-bridge-recovery-settlement-{_hash_identifier(kwargs['request_fingerprint'])}",
+ )
+ setattr(task, "_http_bridge_recovery_session_id", kwargs["session_id"])
+ service._background_cleanup_tasks.add(task)
+
+ def _discard(done_task: asyncio.Task[Any]) -> None:
+ service._background_cleanup_tasks.discard(done_task)
+ try:
+ done_task.result()
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ logger.error("HTTP bridge recovery settlement retry failed", exc_info=True)
+
+ task.add_done_callback(_discard)
+
+
T = TypeVar("T")
_TEXT_DELTA_EVENT_TYPES = frozenset({"response.output_text.delta", "response.refusal.delta"})
_MODEL_OUTPUT_EVENT_TYPES = frozenset(
@@ -460,7 +579,12 @@ async def _http_bridge_receive_timeout_with_eventless_deadline(
return receive_timeout
-async def _cancel_http_bridge_reader_child(task: asyncio.Task[Any] | None, *, label: str) -> bool:
+async def _cancel_http_bridge_reader_child(
+ task: asyncio.Task[Any] | None,
+ *,
+ label: str,
+ cleanup_tasks: set[asyncio.Task[None]] | None = None,
+) -> bool:
if task is None:
return True
if task.done():
@@ -472,7 +596,13 @@ async def _cancel_http_bridge_reader_child(task: asyncio.Task[Any] | None, *, la
logger.debug("HTTP bridge reader child already failed during cleanup label=%s", label, exc_info=True)
return True
try:
- return bool(await _await_cancelled_task(task, label=label))
+ return bool(
+ await _await_cancelled_task(
+ task,
+ label=label,
+ cleanup_tasks=cleanup_tasks,
+ )
+ )
except Exception:
logger.debug("Failed to cancel HTTP bridge reader child label=%s", label, exc_info=True)
return task.done()
@@ -488,6 +618,9 @@ async def _fail_http_bridge_reader_and_maybe_retire(
penalize_account: bool = True,
retire_detail: str | None = None,
force_retire: bool = False,
+ upstream_close_code: int | None = None,
+ response_events_seen: int | None = None,
+ transport_classification: str | None = None,
) -> bool:
session.closed = True
async with session.pending_lock:
@@ -497,6 +630,51 @@ async def _fail_http_bridge_reader_and_maybe_retire(
if _http_bridge_request_counts_against_queue(request_state)
)
session.queued_request_count = max(0, session.queued_request_count - failed_pending_count)
+ observed_response_events = max(
+ (getattr(request_state, "response_event_count", 0) for request_state in session.pending_requests),
+ default=0,
+ )
+ observed_close_code = (
+ upstream_close_code if upstream_close_code is not None else session.last_upstream_close_code
+ )
+ observed_response_events = (
+ response_events_seen if response_events_seen is not None else observed_response_events
+ )
+ close_classification = (
+ _classify_upstream_close(observed_close_code, response_events_seen=observed_response_events)
+ if observed_close_code is not None
+ else None
+ )
+ _log_http_bridge_event(
+ "reader_failure",
+ session.key,
+ account_id=session.account.id,
+ model=session.request_model,
+ pending_count=failed_pending_count,
+ detail=error_code,
+ error_message=_truncate_identifier(error_message),
+ upstream_close_code=observed_close_code,
+ response_events_seen=observed_response_events,
+ transport_classification=transport_classification
+ or (
+ f"websocket_close_{close_classification}"
+ if close_classification is not None
+ else "websocket_transport_error"
+ ),
+ cache_key_family=session.key.affinity_kind,
+ model_class=_extract_model_class(session.request_model) if session.request_model else None,
+ )
+ if force_retire and retire_detail:
+ _log_http_bridge_event(
+ retire_detail,
+ session.key,
+ account_id=session.account.id,
+ model=session.request_model,
+ pending_count=failed_pending_count,
+ detail=retire_detail,
+ cache_key_family=session.key.affinity_kind,
+ model_class=_extract_model_class(session.request_model) if session.request_model else None,
+ )
try:
await self._fail_pending_websocket_requests(
account=session.account,
@@ -511,21 +689,47 @@ async def _fail_http_bridge_reader_and_maybe_retire(
)
finally:
if session.admission_waiter_count > 0 and not force_retire:
+ retry_circuit_detail = None
+ if close_classification == "clean":
+ retry_circuit_detail = "clean_close"
+ elif observed_response_events == 0:
+ retry_circuit_detail = next(
+ (
+ detail
+ for detail in (retire_detail, error_code)
+ if detail in {"stream_incomplete", "stream_idle_timeout", "upstream_keepalive_timeout"}
+ ),
+ None,
+ )
+ if failed_pending_count > 0 and retry_circuit_detail is not None:
+ await self._record_http_bridge_retry_circuit_failure(
+ session,
+ detail=retry_circuit_detail,
+ )
_log_http_bridge_event(
"retire_deferred_for_admission_waiter",
session.key,
account_id=session.account.id,
model=session.request_model,
pending_count=session.admission_waiter_count,
- detail=error_code,
+ detail=retire_detail or error_code,
cache_key_family=session.key.affinity_kind,
model_class=_extract_model_class(session.request_model) if session.request_model else None,
)
else:
- await self._retire_stale_pending_http_bridge_session(
- session,
- detail=retire_detail or error_code,
- )
+ if close_classification == "clean" and failed_pending_count > 0:
+ await self._retire_stale_pending_http_bridge_session(
+ session,
+ detail=error_code,
+ retry_circuit_detail="clean_close",
+ response_events_seen=observed_response_events,
+ )
+ else:
+ await self._retire_stale_pending_http_bridge_session(
+ session,
+ detail=retire_detail or error_code,
+ response_events_seen=observed_response_events,
+ )
return force_retire or session.admission_waiter_count == 0
async def _relay_http_bridge_upstream_messages(
@@ -591,6 +795,7 @@ async def _relay_http_bridge_upstream_messages(
await _cancel_http_bridge_reader_child(
wakeup_task,
label="HTTP bridge reader wakeup wait",
+ cleanup_tasks=self._background_cleanup_tasks,
)
wakeup_task = None
@@ -631,16 +836,31 @@ async def _relay_http_bridge_upstream_messages(
request_state.failure_detail_override = (
_HTTP_BRIDGE_MISSING_RESPONSE_CREATED_TIMEOUT_DETAIL
)
- # Claim the session before cancelling receive so a
- # gate waiter cannot reopen this ambiguous socket.
- session.closed = True
if receive_task is not None:
receive_cancelled = await _cancel_http_bridge_reader_child(
receive_task,
label="HTTP bridge upstream receive after missing response.created",
+ cleanup_tasks=self._background_cleanup_tasks,
)
- if receive_cancelled:
- receive_task = None
+ if not receive_cancelled:
+ # Do not reconnect while the old receive
+ # task still owns the superseded socket.
+ # The ordinary timeout path takes the same
+ # fail-closed branch; retain the explicit
+ # account-neutral timeout classification
+ # rather than routing through the generic
+ # reader-crash account penalty path.
+ session.closed = True
+ await self._fail_http_bridge_reader_and_maybe_retire(
+ session,
+ error_code="upstream_request_timeout",
+ error_message=receive_timeout.error_message,
+ penalize_account=False,
+ retire_detail=_HTTP_BRIDGE_MISSING_RESPONSE_CREATED_TIMEOUT_DETAIL,
+ force_retire=True,
+ )
+ break
+ receive_task = None
_record_http_bridge_stuck_retire(
reason=_HTTP_BRIDGE_MISSING_RESPONSE_CREATED_TIMEOUT_DETAIL,
session=session,
@@ -657,6 +877,17 @@ async def _relay_http_bridge_upstream_messages(
_extract_model_class(session.request_model) if session.request_model else None
),
)
+ # A fresh, self-contained hard request can use the
+ # same bounded pre-created recovery as the idle
+ # timeout path. Keep the session open until the
+ # recovery routine claims the handoff; otherwise
+ # its retry gate would reject the request as
+ # already retired. Continuity-bound requests still
+ # fail closed in _retry_http_bridge_precreated_request.
+ retried = await self._retry_http_bridge_precreated_request(session)
+ if retried:
+ continue
+ session.closed = True
await self._fail_http_bridge_reader_and_maybe_retire(
session,
error_code="upstream_request_timeout",
@@ -671,6 +902,7 @@ async def _relay_http_bridge_upstream_messages(
receive_cancelled = await _cancel_http_bridge_reader_child(
receive_task,
label="HTTP bridge upstream receive after timeout",
+ cleanup_tasks=self._background_cleanup_tasks,
)
if not receive_cancelled:
raise RuntimeError("HTTP bridge upstream receive did not cancel after timeout")
@@ -702,21 +934,53 @@ async def _relay_http_bridge_upstream_messages(
async with session.pending_lock:
archive_request_state = session.pending_requests[0] if len(session.pending_requests) == 1 else None
+ response_events_seen = max(
+ (request_state.response_event_count for request_state in session.pending_requests),
+ default=0,
+ )
_archive_http_bridge_upstream_message(session, message, archive_request_state)
+ session.last_upstream_close_generation += 1
session.last_upstream_close_code = message.close_code
retried = False
- # A process-network receive failure follows a successful send;
- # replay is not safe merely because output is not visible.
+ # A process-network receive failure does not prove that the
+ # upstream rejected response.create. Do not replay ordinary
+ # requests in that ambiguous case: the first request may
+ # still be executing and replay could duplicate work, billing,
+ # or tool side effects. Clean websocket closes remain eligible
+ # for the bounded pre-created retry path below.
if message.error_code != "proxy_network_unavailable":
retried = await self._retry_http_bridge_precreated_request(session)
if retried:
continue
+ close_classification = (
+ _classify_upstream_close(message.close_code, response_events_seen=response_events_seen)
+ if message.close_code is not None
+ else None
+ )
async with session.lifecycle_lock:
await self._fail_http_bridge_reader_and_maybe_retire(
session,
error_code=message.error_code or "stream_incomplete",
error_message=_upstream_websocket_disconnect_message(message),
- penalize_account=message.error_code != "proxy_network_unavailable",
+ upstream_close_code=message.close_code,
+ response_events_seen=response_events_seen,
+ transport_classification=(
+ f"websocket_close_{close_classification}"
+ if close_classification is not None
+ else "websocket_transport_error"
+ ),
+ penalize_account=(
+ message.error_code != "proxy_network_unavailable"
+ and message.error_code != "upstream_keepalive_timeout"
+ and not (
+ message.kind == "close"
+ and _classify_upstream_close(
+ message.close_code,
+ response_events_seen=response_events_seen,
+ )
+ == "clean"
+ )
+ ),
)
break
except asyncio.CancelledError:
@@ -729,7 +993,7 @@ async def _relay_http_bridge_upstream_messages(
exc_info=True,
)
error_code = exc.error_code if isinstance(exc, UpstreamWebSocketTransportError) else "stream_incomplete"
- account_neutral = error_code == "proxy_network_unavailable"
+ account_neutral = error_code in {"proxy_network_unavailable", "upstream_keepalive_timeout"}
async with session.lifecycle_lock:
await self._fail_http_bridge_reader_and_maybe_retire(
session,
@@ -745,10 +1009,12 @@ async def _relay_http_bridge_upstream_messages(
await _cancel_http_bridge_reader_child(
wakeup_task,
label="HTTP bridge reader wakeup wait",
+ cleanup_tasks=self._background_cleanup_tasks,
)
await _cancel_http_bridge_reader_child(
receive_task,
label="HTTP bridge upstream receive",
+ cleanup_tasks=self._background_cleanup_tasks,
)
if session.upstream is relay_upstream:
session.closed = True
@@ -1449,6 +1715,90 @@ async def _process_http_bridge_upstream_text(
completed_usage = None
completed_empty_prewarm = False
+ recovery_attempt_session_id = (
+ matched_request_state.recovery_attempt_session_id
+ if matched_request_state is not None and matched_request_state.recovery_attempt_session_id is not None
+ else session.durable_session_id
+ )
+ recovery_attempt_owner_epoch = (
+ matched_request_state.recovery_attempt_owner_epoch
+ if matched_request_state is not None and matched_request_state.recovery_attempt_owner_epoch is not None
+ else session.durable_owner_epoch
+ )
+
+ if (
+ isinstance(event_type, str)
+ and event_type.startswith("response.")
+ and matched_request_state is not None
+ and matched_request_state.recovery_attempt_fingerprint is not None
+ and recovery_attempt_session_id is not None
+ and recovery_attempt_owner_epoch is not None
+ and (event_type == "response.completed" or not matched_request_state.recovery_attempt_event_observed)
+ ):
+ settlement_marked = False
+ for settlement_attempt in range(3):
+ try:
+ marked = await self._durable_bridge.mark_recovery_attempt_replayed(
+ session_id=recovery_attempt_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=recovery_attempt_owner_epoch,
+ request_fingerprint=matched_request_state.recovery_attempt_fingerprint,
+ response_id=response_id,
+ )
+ if marked:
+ settlement_marked = True
+ break
+ if settlement_attempt == 2:
+ _schedule_http_bridge_recovery_settlement_retry(
+ self,
+ session,
+ session_id=recovery_attempt_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=recovery_attempt_owner_epoch,
+ request_fingerprint=matched_request_state.recovery_attempt_fingerprint,
+ response_id=response_id,
+ release_origin_lease=(
+ recovery_attempt_session_id != session.durable_session_id
+ and event_type in {"response.completed", "response.failed"}
+ ),
+ )
+ except Exception:
+ if settlement_attempt == 2:
+ logger.warning("Failed to settle HTTP bridge recovery attempt", exc_info=True)
+ _schedule_http_bridge_recovery_settlement_retry(
+ self,
+ session,
+ session_id=recovery_attempt_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=recovery_attempt_owner_epoch,
+ request_fingerprint=matched_request_state.recovery_attempt_fingerprint,
+ response_id=response_id,
+ release_origin_lease=(
+ recovery_attempt_session_id != session.durable_session_id
+ and event_type in {"response.completed", "response.failed"}
+ ),
+ )
+ else:
+ await asyncio.sleep(0.05 * (settlement_attempt + 1))
+ if (
+ settlement_marked
+ and event_type in {"response.completed", "response.failed"}
+ and recovery_attempt_session_id != session.durable_session_id
+ ):
+ try:
+ await self._durable_bridge.release_live_session(
+ session_id=recovery_attempt_session_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=recovery_attempt_owner_epoch,
+ draining=False,
+ )
+ except Exception:
+ logger.debug("Failed to release HTTP bridge recovery origin lease", exc_info=True)
+ matched_request_state.recovery_attempt_event_observed = True
+
if event_type == "response.completed" and terminal_request_state is not None and not completed_empty_prewarm:
# Record the completed response id regardless of input shape so
# subsequent turns (including ones that never populated
@@ -1467,6 +1817,15 @@ async def _process_http_bridge_upstream_text(
session.last_completed_input_count = terminal_request_state.input_item_count
session.last_completed_input_prefix_fingerprint = terminal_request_state.input_full_fingerprint
+ if (
+ event_type == "response.completed"
+ and terminal_request_state is not None
+ and not terminal_request_state.suppressed_duplicate_tool_call
+ and terminal_request_state.request_kind != "prewarm"
+ and not terminal_request_state.skip_request_log
+ ):
+ await self._clear_http_bridge_retry_circuit(session)
+
normalize_error_event = (
terminal_request_state is None or terminal_request_state.enforce_openai_sdk_contract
) and (matched_request_state is None or matched_request_state.enforce_openai_sdk_contract)
@@ -1505,6 +1864,71 @@ async def _process_http_bridge_upstream_text(
request_state=terminal_request_state or matched_request_state,
)
+ if (
+ settlement_event_type in {"response.failed", "error"}
+ and matched_request_state is not None
+ and matched_request_state.recovery_attempt_fingerprint is not None
+ and recovery_attempt_session_id is not None
+ and recovery_attempt_owner_epoch is not None
+ and not matched_request_state.recovery_attempt_event_observed
+ ):
+ # An explicit deterministic failure is terminal evidence for the
+ # journaled request, not an ambiguous transport outcome. Consume
+ # the UNKNOWN row after normalizing top-level errors so a later
+ # identical retry cannot turn it into an account-neutral replay.
+ deterministic_settlement_marked = False
+ for settlement_attempt in range(3):
+ try:
+ marked = await self._durable_bridge.mark_recovery_attempt_replayed(
+ session_id=recovery_attempt_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=recovery_attempt_owner_epoch,
+ request_fingerprint=matched_request_state.recovery_attempt_fingerprint,
+ response_id=response_id,
+ )
+ if marked:
+ deterministic_settlement_marked = True
+ break
+ if settlement_attempt == 2:
+ _schedule_http_bridge_recovery_settlement_retry(
+ self,
+ session,
+ session_id=recovery_attempt_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=recovery_attempt_owner_epoch,
+ request_fingerprint=matched_request_state.recovery_attempt_fingerprint,
+ response_id=response_id,
+ release_origin_lease=recovery_attempt_session_id != session.durable_session_id,
+ )
+ except Exception:
+ if settlement_attempt == 2:
+ logger.warning("Failed to settle deterministic HTTP bridge recovery attempt", exc_info=True)
+ _schedule_http_bridge_recovery_settlement_retry(
+ self,
+ session,
+ session_id=recovery_attempt_session_id,
+ api_key_id=session.key.api_key_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=recovery_attempt_owner_epoch,
+ request_fingerprint=matched_request_state.recovery_attempt_fingerprint,
+ response_id=response_id,
+ release_origin_lease=recovery_attempt_session_id != session.durable_session_id,
+ )
+ else:
+ await asyncio.sleep(0.05 * (settlement_attempt + 1))
+ if deterministic_settlement_marked and recovery_attempt_session_id != session.durable_session_id:
+ try:
+ await self._durable_bridge.release_live_session(
+ session_id=recovery_attempt_session_id,
+ instance_id=_service_get_settings().http_responses_session_bridge_instance_id,
+ owner_epoch=recovery_attempt_owner_epoch,
+ draining=False,
+ )
+ except Exception:
+ logger.debug("Failed to release HTTP bridge recovery origin lease", exc_info=True)
+
if event_type == "response.created" and release_create_gate and created_request_state is not None:
await _release_websocket_response_create_gate(created_request_state, session.response_create_gate)
diff --git a/app/modules/proxy/_service/request_log.py b/app/modules/proxy/_service/request_log.py
index 654b1aec4d..67a3c9c2e7 100644
--- a/app/modules/proxy/_service/request_log.py
+++ b/app/modules/proxy/_service/request_log.py
@@ -22,11 +22,12 @@
"proxy-request-log-",
"proxy-stream-api-key-settle-",
"proxy-release_stream_api_key_reservation",
+ "http-bridge-recovery-settlement-",
)
-def _is_persistence_task(task: asyncio.Task[None]) -> bool:
- return task.get_name().startswith(_PERSISTENCE_TASK_NAME_PREFIXES)
+def _is_persistence_task(task: asyncio.Task[None], prefixes: tuple[str, ...] | None = None) -> bool:
+ return task.get_name().startswith(prefixes or _PERSISTENCE_TASK_NAME_PREFIXES)
_REQUEST_TRANSPORT_HTTP = "http"
@@ -291,7 +292,11 @@ async def _write_request_log(
model=model,
)
- async def drain_persistence_tasks(self, timeout_seconds: float) -> bool:
+ async def drain_persistence_tasks(
+ self,
+ timeout_seconds: float,
+ task_name_prefixes: tuple[str, ...] | None = None,
+ ) -> bool:
"""Await detached request-log and settlement tasks, e.g. at shutdown.
Persistence runs detached from the response path, so a graceful
@@ -309,14 +314,14 @@ async def drain_persistence_tasks(self, timeout_seconds: float) -> bool:
pending = {
task
for task in (proxy._request_log_tasks | proxy._background_cleanup_tasks)
- if not task.done() and _is_persistence_task(task)
+ if not task.done() and _is_persistence_task(task, task_name_prefixes)
}
if not pending:
# One scheduling tick so just-finished tasks' done callbacks
# (which may enqueue follow-up tasks) run before we re-check.
await asyncio.sleep(0)
if not any(
- _is_persistence_task(task)
+ _is_persistence_task(task, task_name_prefixes)
for task in (proxy._request_log_tasks | proxy._background_cleanup_tasks)
if not task.done()
):
diff --git a/app/modules/proxy/_service/streaming/helpers.py b/app/modules/proxy/_service/streaming/helpers.py
index 0750b6e077..a02f6a64e1 100644
--- a/app/modules/proxy/_service/streaming/helpers.py
+++ b/app/modules/proxy/_service/streaming/helpers.py
@@ -467,9 +467,13 @@ def _classify_upstream_close(
close_code: int | None,
*,
response_events_seen: int,
-) -> Literal["transient", "rejected"]:
+) -> Literal["clean", "transient"]:
if close_code == 1000 and response_events_seen == 0:
- return "rejected"
+ # A clean websocket close before response.created does not prove that
+ # the request was invalid. The upstream can close a socket during a
+ # handoff, so the caller may safely recreate the socket and replay the
+ # still-unstarted request once.
+ return "clean"
return "transient"
diff --git a/app/modules/proxy/_service/support.py b/app/modules/proxy/_service/support.py
index 141b2b5570..b0900087d3 100644
--- a/app/modules/proxy/_service/support.py
+++ b/app/modules/proxy/_service/support.py
@@ -77,6 +77,7 @@
}
)
_ACCOUNT_SELECTION_RECOVERY_MIN_SLEEP_SECONDS = 1.0
+_HARD_AFFINITY_RECOVERY_SLEEP_SECONDS = 2.0
_ACCOUNT_SELECTION_RECOVERY_DEFAULT_SLEEP_SECONDS = 30.0
_ACCOUNT_SELECTION_RECOVERY_MAX_SLEEP_SECONDS = 300.0
_ACCOUNT_SELECTION_RECOVERY_HEARTBEAT_SECONDS = 10.0
@@ -321,6 +322,13 @@ def _account_selection_recovery_sleep_seconds_from_message(
if "hit your spend cap set by the owner of your workspace" in lowered:
return _ACCOUNT_SELECTION_RECOVERY_DEFAULT_SLEEP_SECONDS
+ # A hard affinity row is an ownership constraint, so it must not spill to
+ # another account. Capacity/health transitions can nevertheless make the
+ # owner briefly unavailable; give that owner a short recovery window before
+ # surfacing a 503 rather than rebinding the logical session.
+ if error_code == "hard_affinity_saturated":
+ return _HARD_AFFINITY_RECOVERY_SLEEP_SECONDS
+
if error_code in _LOCAL_ACCOUNT_CAP_ERROR_CODES:
return _ACCOUNT_SELECTION_RECOVERY_DEFAULT_SLEEP_SECONDS
@@ -786,6 +794,13 @@ class _WebSocketRequestState:
request_usage_budget: ApiKeyRequestUsageBudget | None = None
request_text: str | None = None
replay_count: int = 0
+ # Counts only the one extra replay permitted after the initial recovery
+ # replay when the replacement upstream socket also closes cleanly before
+ # producing any response event.
+ clean_close_replay_count: int = 0
+ clean_close_retry_in_progress: bool = False
+ clean_close_retry_result: bool | None = None
+ clean_close_retry_close_generation: int | None = None
auth_replay_count: int = 0
auth_replay_counts_by_account: dict[str, int] = field(default_factory=dict)
force_refresh_account_id: str | None = None
@@ -799,6 +814,9 @@ class _WebSocketRequestState:
skip_request_log: bool = False
previous_response_id: str | None = None
session_id: str | None = None
+ # Session headers provide locality, but only a previous response or
+ # explicit turn-state header guarantees continuity for stale recovery.
+ hard_continuity_anchor: bool = False
proxy_injected_previous_response_id: bool = False
expose_stale_previous_response_classifier: bool = False
fresh_upstream_request_text: str | None = None
@@ -811,6 +829,19 @@ class _WebSocketRequestState:
# on, and dropping the anchor there would silently turn a continuation into
# a context-free fresh turn.
fresh_upstream_request_is_retry_safe: bool = False
+ # Stable fingerprint used by the durable recovery-attempt journal. It is
+ # populated only for a proof-gated fresh replay candidate.
+ recovery_attempt_fingerprint: str | None = None
+ recovery_attempt_session_id: str | None = None
+ recovery_attempt_owner_epoch: int | None = None
+ # True when recovery already atomically claimed the journal row as
+ # REPLAYED. Claimed replays must not be re-journaled at dispatch.
+ recovery_attempt_claimed: bool = False
+ # Set once the upstream send is attempted, including an ambiguous send
+ # failure. Pre-dispatch admission/setup failures may safely roll back a
+ # claimed recovery journal; an attempted send must remain consumed.
+ recovery_attempt_dispatched: bool = False
+ recovery_attempt_event_observed: bool = False
# Responses-Lite model advertised by ``fresh_upstream_request_text``. A
# fresh replay built from a trusted marker-only frame has the reserved
# marker stripped, so swapping to the fresh body must also swap this onto
@@ -832,6 +863,7 @@ class _WebSocketRequestState:
upstream_error_code_override: str | None = None
error_http_status_override: int | None = None
response_event_count: int = 0
+ last_upstream_activity_at: float | None = None
upstream_model_output_seen: bool = False
previous_response_not_found_rewritten: bool = False
previous_response_owner_lookup_source: str | None = None
@@ -946,7 +978,13 @@ class _HTTPBridgeSession:
durable_owner_epoch: int | None = None
upstream_reader: asyncio.Task[None] | None = None
last_upstream_close_code: int | None = None
+ last_upstream_close_generation: int = 0
closed: bool = False
+ # Set while a reader handoff is replacing the socket. Idle pruning must
+ # retain the registered session during this short transition even though
+ # ``closed`` is fail-closed for normal request reuse.
+ handoff_in_progress: bool = False
+ handoff_future: asyncio.Future["_HTTPBridgeSession"] | None = None
account_lease: AccountLease | None = None
upstream_close_attempted: bool = False
seen_tool_call_keys: dict[ToolCallDedupeKey, None] = field(default_factory=dict)
@@ -957,6 +995,19 @@ class _HTTPBridgeSession:
upstream_proxy_fail_closed_reason: str | None = None
+def _complete_http_bridge_handoff(
+ session: _HTTPBridgeSession,
+ inflight_sessions: dict[_HTTPBridgeSessionKey, asyncio.Future[_HTTPBridgeSession]],
+) -> None:
+ session.handoff_in_progress = False
+ future = session.handoff_future
+ session.handoff_future = None
+ if future is not None and not future.done():
+ future.set_result(session)
+ if inflight_sessions.get(session.key) is future:
+ inflight_sessions.pop(session.key, None)
+
+
def _http_bridge_session_supports_service_tier(
session: _HTTPBridgeSession,
*,
@@ -1150,15 +1201,25 @@ def _clear_websocket_deferred_reasoning_downstream_texts(request_state: _WebSock
def _record_response_event(request_state: _WebSocketRequestState | None, event_type: str | None) -> None:
if request_state is None or event_type is None or not event_type.startswith("response."):
return
+ request_state.last_upstream_activity_at = time.monotonic()
if event_type in {"response.failed", "response.incomplete"}:
return
request_state.response_event_count += 1
-def _websocket_request_can_replay_before_visible_output(request_state: _WebSocketRequestState) -> bool:
+def _websocket_request_can_replay_before_visible_output(
+ request_state: _WebSocketRequestState,
+ *,
+ allow_clean_close_retry: bool = False,
+) -> bool:
if not request_state.request_text:
return False
- if request_state.replay_count >= 1:
+ if request_state.replay_count >= 1 and not (
+ allow_clean_close_retry
+ and request_state.replay_count == 1
+ and request_state.response_event_count == 0
+ and request_state.clean_close_replay_count == 0
+ ):
return False
sequenced_created_only_prewarm = (
request_state.generate_false_prewarm
diff --git a/app/modules/proxy/_service/websocket/mixin.py b/app/modules/proxy/_service/websocket/mixin.py
index 63d5a5687d..ba65640c73 100644
--- a/app/modules/proxy/_service/websocket/mixin.py
+++ b/app/modules/proxy/_service/websocket/mixin.py
@@ -854,6 +854,7 @@ async def retire_current_upstream() -> None:
await _facade()._await_cancelled_task(
upstream_reader,
label="proxy websocket upstream reader",
+ cleanup_tasks=proxy._background_cleanup_tasks,
)
upstream_reader = None
upstream_control = None
@@ -1723,7 +1724,11 @@ async def retire_current_upstream() -> None:
downstream_activity,
)
if upstream_reader is not None:
- await _facade()._await_cancelled_task(upstream_reader, label="proxy websocket upstream reader")
+ await _facade()._await_cancelled_task(
+ upstream_reader,
+ label="proxy websocket upstream reader",
+ cleanup_tasks=proxy._background_cleanup_tasks,
+ )
upstream_reader = None
upstream_control = None
if upstream is not None:
@@ -1753,7 +1758,9 @@ async def retire_current_upstream() -> None:
replay_request_state = replay_candidate
if upstream_reader is not None:
await _facade()._await_cancelled_task(
- upstream_reader, label="proxy websocket upstream reader"
+ upstream_reader,
+ label="proxy websocket upstream reader",
+ cleanup_tasks=proxy._background_cleanup_tasks,
)
upstream_reader = None
upstream_control = None
@@ -1790,7 +1797,11 @@ async def retire_current_upstream() -> None:
downstream_activity,
)
if upstream_reader is not None:
- await _facade()._await_cancelled_task(upstream_reader, label="proxy websocket upstream reader")
+ await _facade()._await_cancelled_task(
+ upstream_reader,
+ label="proxy websocket upstream reader",
+ cleanup_tasks=proxy._background_cleanup_tasks,
+ )
upstream_reader = None
upstream_control = None
if upstream is not None:
@@ -1806,7 +1817,11 @@ async def retire_current_upstream() -> None:
continue
finally:
if upstream_reader is not None:
- await _facade()._await_cancelled_task(upstream_reader, label="proxy websocket upstream reader")
+ await _facade()._await_cancelled_task(
+ upstream_reader,
+ label="proxy websocket upstream reader",
+ cleanup_tasks=proxy._background_cleanup_tasks,
+ )
if upstream is not None:
try:
await upstream.close()
@@ -3782,6 +3797,14 @@ async def _relay_upstream_websocket_messages(
penalize_account=message.error_code != "proxy_network_unavailable",
suppress_sequenced_downstream_errors=sequenced_downstream_replay_refused,
)
+ # A terminal receive can race the outer session loop's
+ # cleanup (especially when the downstream closes as soon as
+ # it receives the failure event). Close here as well so the
+ # transport is retired before the reader task exits.
+ try:
+ await upstream.close()
+ except Exception:
+ _facade().logger.debug("Failed to close upstream websocket after terminal receive", exc_info=True)
if sequenced_downstream_replay_refused:
await _close_downstream_after_sequenced_replay_refusal(
websocket,
diff --git a/app/modules/proxy/_service/websocket/protocol.py b/app/modules/proxy/_service/websocket/protocol.py
index 521c5928a7..9eaed0669e 100644
--- a/app/modules/proxy/_service/websocket/protocol.py
+++ b/app/modules/proxy/_service/websocket/protocol.py
@@ -5,6 +5,7 @@
class _WebSocketServiceProtocol(Protocol):
_capability_router: Any
+ _background_cleanup_tasks: Any
_acquire_account_response_create_lease_or_overload: Any
_acquire_request_state_response_create_admission: Any
_cancel_request_state_api_key_reservation_heartbeat: Any
diff --git a/app/modules/proxy/api.py b/app/modules/proxy/api.py
index 296979a67d..abe50df1b2 100644
--- a/app/modules/proxy/api.py
+++ b/app/modules/proxy/api.py
@@ -3,6 +3,7 @@
import asyncio
import json
import logging
+import math
import time
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping
from contextlib import asynccontextmanager
@@ -82,7 +83,11 @@
ProxyRateLimitError,
ProxyUpstreamError,
)
-from app.core.metrics.prometheus import PROMETHEUS_AVAILABLE, bridge_public_contract_error_total
+from app.core.metrics.prometheus import (
+ PROMETHEUS_AVAILABLE,
+ bridge_public_contract_error_total,
+ stream_keepalive_sent_total,
+)
from app.core.middleware.multipart_content_encoding import raise_for_unsupported_multipart_content_encoding
from app.core.multipart import (
IMAGE_EDITS_MULTIPART_POLICY,
@@ -720,6 +725,11 @@ async def _thread_goal_proxy(
}
)
+# A hard HTTP-bridge circuit is opened only after an ambiguous upstream turn
+# failure. The caller must not immediately replay that turn, but it should
+# also not have to guess when a new attempt is safe. Advertise a short,
+# bounded retry interval on the one-shot 503 response.
+
def _codex_control_downstream_headers(headers: Mapping[str, str]) -> dict[str, str]:
return {key: value for key, value in headers.items() if key.lower() in _CODEX_CONTROL_RESPONSE_HEADERS}
@@ -3871,6 +3881,7 @@ async def v1_chat_completions(
inject_sse_keepalives(
chat_stream,
get_settings().sse_keepalive_interval_seconds,
+ on_keepalive=lambda: _record_stream_keepalive("chat_completions"),
),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", **rate_limit_headers},
@@ -5036,6 +5047,7 @@ async def _stream_responses(
stream,
get_settings().sse_keepalive_interval_seconds,
keepalive_frame=keepalive_frame,
+ on_keepalive=lambda: _record_stream_keepalive("responses"),
),
media_type="text/event-stream",
headers={
@@ -6089,6 +6101,11 @@ async def _prepend_initial_sse_heartbeat(
yield line
+def _record_stream_keepalive(surface: str) -> None:
+ if PROMETHEUS_AVAILABLE and stream_keepalive_sent_total is not None:
+ stream_keepalive_sent_total.labels(surface=surface).inc()
+
+
async def _stream_proxy_errors_as_response_failed(stream: AsyncIterator[str]) -> AsyncIterator[str]:
async for line in _stream_response_error_events(stream, owns_reservation=False, reservation=None):
yield line
@@ -6112,7 +6129,15 @@ async def _stream_response_error_events(
envelope = _parse_error_envelope(exc.payload)
_, envelope = _mask_previous_response_not_found_error(envelope, default_status=exc.status_code)
error = envelope.error
- yield format_sse_event(
+ retry_hint = ""
+ if exc.retry_after_seconds is not None and exc.retry_after_seconds > 0:
+ # Preserve the HTTP Retry-After signal when a streaming response
+ # has already started and the exception must be represented as an
+ # SSE event. The SSE retry field is milliseconds, while the
+ # exception stores seconds. Clients that do not implement the
+ # directive safely ignore the extra comment line.
+ retry_hint = f"retry: {max(1, math.ceil(exc.retry_after_seconds * 1000))}\n"
+ yield retry_hint + format_sse_event(
response_failed_event(
error.code if error and error.code else "upstream_error",
error.message if error and error.message else "Upstream error",
@@ -6131,11 +6156,14 @@ def _stream_startup_error_response(
if isinstance(error, ProxyResponseError):
envelope = _parse_error_envelope(error.payload)
status_code, envelope = _mask_previous_response_not_found_error(envelope, default_status=error.status_code)
+ startup_headers = dict(headers)
+ if error.retry_after_seconds is not None and error.retry_after_seconds > 0:
+ startup_headers.setdefault("Retry-After", str(error.retry_after_seconds))
return _logged_error_json_response(
request,
status_code,
envelope.model_dump(mode="json", exclude_none=True),
- headers=headers,
+ headers=startup_headers,
)
status_code, envelope = _mask_previous_response_not_found_error(error)
return _logged_error_json_response(
diff --git a/app/modules/proxy/durable_bridge_coordinator.py b/app/modules/proxy/durable_bridge_coordinator.py
index 75e397a7d2..a6c125b70b 100644
--- a/app/modules/proxy/durable_bridge_coordinator.py
+++ b/app/modules/proxy/durable_bridge_coordinator.py
@@ -15,7 +15,9 @@
from app.modules.proxy.durable_bridge_repository import (
DurableBridgeAliasRegistration,
DurableBridgeAliasRegistrationReceipt,
+ DurableBridgeRecoveryAttemptSnapshot,
DurableBridgeRepository,
+ DurableBridgeRetryCircuitSnapshot,
DurableBridgeSessionSnapshot,
durable_bridge_api_key_scope,
)
@@ -194,6 +196,92 @@ async def lookup_sessions(self, *, session_ids: Sequence[str]) -> list[DurableBr
snapshots = await DurableBridgeRepository(session).get_sessions_by_ids(session_ids)
return [_to_lookup(snapshot) for snapshot in snapshots]
+ async def lookup_retry_circuit(
+ self,
+ *,
+ session_key_kind: str,
+ session_key_value: str,
+ api_key_id: str | None,
+ ) -> DurableBridgeRetryCircuitSnapshot | None:
+ async with self._session() as session:
+ return await DurableBridgeRepository(session).get_retry_circuit(
+ session_key_kind=session_key_kind,
+ session_key_value=session_key_value,
+ api_key_scope=durable_bridge_api_key_scope(api_key_id),
+ )
+
+ async def persist_retry_circuit(
+ self,
+ *,
+ session_key_kind: str,
+ session_key_value: str,
+ api_key_id: str | None,
+ consecutive_failures: int,
+ cooldown_until_epoch: float,
+ last_detail: str | None,
+ updated_at_epoch: float,
+ base_updated_at_epoch: float = 0.0,
+ failure_threshold: int = 1,
+ conflict_cooldown_until_epoch: float | None = None,
+ base_backoff_seconds: float = 60.0,
+ max_backoff_seconds: float = 600.0,
+ clean_close_max_backoff_seconds: float = 30.0,
+ ) -> DurableBridgeRetryCircuitSnapshot | None:
+ async with self._session() as session:
+ repository = DurableBridgeRepository(session)
+ await repository.upsert_retry_circuit(
+ session_key_kind=session_key_kind,
+ session_key_value=session_key_value,
+ api_key_scope=durable_bridge_api_key_scope(api_key_id),
+ consecutive_failures=consecutive_failures,
+ cooldown_until_epoch=cooldown_until_epoch,
+ last_detail=last_detail,
+ updated_at_epoch=updated_at_epoch,
+ base_updated_at_epoch=base_updated_at_epoch,
+ failure_threshold=failure_threshold,
+ conflict_cooldown_until_epoch=conflict_cooldown_until_epoch,
+ base_backoff_seconds=base_backoff_seconds,
+ max_backoff_seconds=max_backoff_seconds,
+ clean_close_max_backoff_seconds=clean_close_max_backoff_seconds,
+ )
+ return await repository.get_retry_circuit(
+ session_key_kind=session_key_kind,
+ session_key_value=session_key_value,
+ api_key_scope=durable_bridge_api_key_scope(api_key_id),
+ )
+
+ async def clear_retry_circuit(
+ self,
+ *,
+ session_key_kind: str,
+ session_key_value: str,
+ api_key_id: str | None,
+ expected_updated_at_epoch: float | None = None,
+ ) -> None:
+ async with self._session() as session:
+ await DurableBridgeRepository(session).delete_retry_circuit(
+ session_key_kind=session_key_kind,
+ session_key_value=session_key_value,
+ api_key_scope=durable_bridge_api_key_scope(api_key_id),
+ expected_updated_at_epoch=expected_updated_at_epoch,
+ )
+
+ async def purge_retry_circuit(
+ self,
+ *,
+ session_key_kind: str,
+ session_key_value: str,
+ api_key_id: str | None,
+ expected_updated_at_epoch: float | None = None,
+ ) -> None:
+ async with self._session() as session:
+ await DurableBridgeRepository(session).purge_retry_circuit(
+ session_key_kind=session_key_kind,
+ session_key_value=session_key_value,
+ api_key_scope=durable_bridge_api_key_scope(api_key_id),
+ expected_updated_at_epoch=expected_updated_at_epoch,
+ )
+
async def claim_live_session(
self,
*,
@@ -261,6 +349,26 @@ async def renew_live_session(
return None
return _to_lookup(snapshot)
+ async def rebind_session_account(
+ self,
+ *,
+ session_id: str,
+ api_key_id: str | None,
+ instance_id: str,
+ owner_epoch: int,
+ account_id: str,
+ clear_continuity: bool = False,
+ ) -> bool:
+ del api_key_id
+ async with self._session() as session:
+ return await DurableBridgeRepository(session).rebind_session_account(
+ session_id=session_id,
+ instance_id=instance_id,
+ owner_epoch=owner_epoch,
+ account_id=account_id,
+ clear_continuity=clear_continuity,
+ )
+
async def release_live_session(
self,
*,
@@ -280,6 +388,82 @@ async def release_live_session(
return None
return _to_lookup(snapshot)
+ async def record_recovery_attempt(
+ self,
+ *,
+ session_id: str,
+ api_key_id: str | None,
+ instance_id: str,
+ owner_epoch: int,
+ request_fingerprint: str,
+ request_id: str,
+ account_id: str | None,
+ model: str | None,
+ replay_safe: bool,
+ ) -> DurableBridgeRecoveryAttemptSnapshot | None:
+ del api_key_id
+ async with self._session() as session:
+ return await DurableBridgeRepository(session).record_recovery_attempt(
+ session_id=session_id,
+ instance_id=instance_id,
+ owner_epoch=owner_epoch,
+ request_fingerprint=request_fingerprint,
+ request_id=request_id,
+ account_id=account_id,
+ model=model,
+ replay_safe=replay_safe,
+ )
+
+ async def lookup_recovery_attempt(
+ self,
+ *,
+ session_id: str,
+ request_fingerprint: str,
+ ) -> DurableBridgeRecoveryAttemptSnapshot | None:
+ async with self._session() as session:
+ return await DurableBridgeRepository(session).lookup_recovery_attempt(
+ session_id=session_id,
+ request_fingerprint=request_fingerprint,
+ )
+
+ async def mark_recovery_attempt_replayed(
+ self,
+ *,
+ session_id: str,
+ api_key_id: str | None,
+ instance_id: str,
+ owner_epoch: int,
+ request_fingerprint: str,
+ response_id: str | None = None,
+ ) -> bool:
+ del api_key_id
+ async with self._session() as session:
+ return await DurableBridgeRepository(session).mark_recovery_attempt_replayed(
+ session_id=session_id,
+ instance_id=instance_id,
+ owner_epoch=owner_epoch,
+ request_fingerprint=request_fingerprint,
+ response_id=response_id,
+ )
+
+ async def rollback_recovery_attempt_replayed(
+ self,
+ *,
+ session_id: str,
+ api_key_id: str | None,
+ instance_id: str,
+ owner_epoch: int,
+ request_fingerprint: str,
+ ) -> bool:
+ del api_key_id
+ async with self._session() as session:
+ return await DurableBridgeRepository(session).rollback_recovery_attempt_replayed(
+ session_id=session_id,
+ instance_id=instance_id,
+ owner_epoch=owner_epoch,
+ request_fingerprint=request_fingerprint,
+ )
+
async def mark_instance_draining(self, *, instance_id: str) -> int:
async with self._session() as session:
return await DurableBridgeRepository(session).mark_owner_draining(instance_id=instance_id)
diff --git a/app/modules/proxy/durable_bridge_repository.py b/app/modules/proxy/durable_bridge_repository.py
index 7b82e7f8a3..eefa4d1a45 100644
--- a/app/modules/proxy/durable_bridge_repository.py
+++ b/app/modules/proxy/durable_bridge_repository.py
@@ -1,20 +1,29 @@
from __future__ import annotations
import json
+import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import StrEnum
from hashlib import sha256
+from typing import Any
-from sqlalchemy import Row, and_, case, delete, or_, select, text, update
+from sqlalchemy import Row, and_, case, delete, func, or_, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.utils.time import to_utc_naive, utcnow
-from app.db.models import HttpBridgeSessionAlias, HttpBridgeSessionRecord, HttpBridgeSessionState
+from app.db.models import (
+ HttpBridgeRecoveryAttemptRecord,
+ HttpBridgeRecoveryAttemptState,
+ HttpBridgeRetryCircuit,
+ HttpBridgeSessionAlias,
+ HttpBridgeSessionRecord,
+ HttpBridgeSessionState,
+)
from app.db.session import sqlite_writer_section
from app.modules.proxy.continuity import (
HTTP_BRIDGE_ACCOUNT_NEUTRAL_REPLAY_KEY_PREFIX,
@@ -27,7 +36,10 @@
REQUIRED_DURABLE_BRIDGE_TABLES = (
"http_bridge_sessions",
"http_bridge_session_aliases",
+ "http_bridge_retry_circuits",
+ "http_bridge_recovery_attempts",
)
+DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS = 3600.0
_PURGE_CLOSED_BATCH_SIZE = 500
_SESSION_ID_LOOKUP_CHUNK_SIZE = 500
@@ -120,6 +132,29 @@ class DurableBridgeSessionSnapshot:
latest_pending_tool_calls: dict[str, str] | None = None
+@dataclass(frozen=True, slots=True)
+class DurableBridgeRetryCircuitSnapshot:
+ session_key_kind: str
+ session_key_hash: str
+ api_key_scope: str
+ consecutive_failures: int
+ cooldown_until_epoch: float
+ last_detail: str | None
+ updated_at_epoch: float
+
+
+@dataclass(frozen=True, slots=True)
+class DurableBridgeRecoveryAttemptSnapshot:
+ session_id: str
+ request_fingerprint: str
+ request_id: str
+ account_id: str | None
+ model: str | None
+ replay_safe: bool
+ state: HttpBridgeRecoveryAttemptState
+ response_id: str | None
+
+
class DurableBridgeRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
@@ -144,6 +179,286 @@ async def get_session(
row = result.scalar_one_or_none()
return _to_snapshot(row)
+ async def get_retry_circuit(
+ self,
+ *,
+ session_key_kind: str,
+ session_key_value: str,
+ api_key_scope: str,
+ ) -> DurableBridgeRetryCircuitSnapshot | None:
+ result = await self._session.execute(
+ select(HttpBridgeRetryCircuit).where(
+ HttpBridgeRetryCircuit.session_key_kind == session_key_kind,
+ HttpBridgeRetryCircuit.session_key_hash == durable_bridge_hash(session_key_value),
+ HttpBridgeRetryCircuit.api_key_scope == api_key_scope,
+ )
+ )
+ return _to_retry_circuit_snapshot(result.scalar_one_or_none())
+
+ async def upsert_retry_circuit(
+ self,
+ *,
+ session_key_kind: str,
+ session_key_value: str,
+ api_key_scope: str,
+ consecutive_failures: int,
+ cooldown_until_epoch: float,
+ last_detail: str | None,
+ updated_at_epoch: float,
+ base_updated_at_epoch: float = 0.0,
+ failure_threshold: int = 1,
+ conflict_cooldown_until_epoch: float | None = None,
+ base_backoff_seconds: float = 60.0,
+ max_backoff_seconds: float = 600.0,
+ clean_close_max_backoff_seconds: float = 30.0,
+ ) -> None:
+ values = {
+ "session_key_kind": session_key_kind,
+ "session_key_hash": durable_bridge_hash(session_key_value),
+ "api_key_scope": api_key_scope,
+ "consecutive_failures": consecutive_failures,
+ "cooldown_until_epoch": cooldown_until_epoch,
+ "last_detail": last_detail,
+ "updated_at_epoch": updated_at_epoch,
+ }
+ threshold = max(1, failure_threshold)
+ cooldown_floor = (
+ max(0.0, conflict_cooldown_until_epoch)
+ if conflict_cooldown_until_epoch is not None
+ else max(0.0, cooldown_until_epoch)
+ )
+ # A reset starts a new failure lineage. Never carry the incoming
+ # cooldown into that fresh lineage, even when the threshold is one.
+ reset_failure_cooldown = 0.0
+ base_backoff = max(0.001, base_backoff_seconds)
+ max_backoff = max(base_backoff, max_backoff_seconds)
+ clean_close_max_backoff = max(0.001, clean_close_max_backoff_seconds)
+
+ def cooldown_for_failure_count(failure_count: Any, last_detail: Any) -> Any:
+ regular_cooldown = case(
+ (failure_count < threshold, 0.0),
+ (failure_count == threshold, base_backoff),
+ (failure_count == threshold + 1, min(max_backoff, base_backoff * 2.0)),
+ (failure_count == threshold + 2, min(max_backoff, base_backoff * 4.0)),
+ else_=max_backoff,
+ )
+ clean_cooldown = case(
+ (failure_count < threshold, 0.0),
+ else_=clean_close_max_backoff,
+ )
+ return case((last_detail == "clean_close", clean_cooldown), else_=regular_cooldown)
+
+ dialect = self._session.get_bind().dialect.name
+ if dialect == "postgresql":
+ insert_statement = pg_insert(HttpBridgeRetryCircuit).values(**values)
+ excluded = insert_statement.excluded
+ reset_lineage = and_(
+ HttpBridgeRetryCircuit.consecutive_failures == 0,
+ HttpBridgeRetryCircuit.cooldown_until_epoch <= 0,
+ HttpBridgeRetryCircuit.last_detail.is_(None),
+ HttpBridgeRetryCircuit.updated_at_epoch > base_updated_at_epoch,
+ )
+ # ``updated_at_epoch`` is an observation timestamp, not a
+ # concurrency version. Treat an unchanged loaded row as a CAS
+ # match, even when a replica's wall clock lags it. The failure
+ # count guard still rejects an older snapshot that was loaded from
+ # the same row after a newer failure had already been merged.
+ failure_from_loaded_row = and_(
+ HttpBridgeRetryCircuit.updated_at_epoch == base_updated_at_epoch,
+ excluded.consecutive_failures >= HttpBridgeRetryCircuit.consecutive_failures,
+ )
+ failure_is_newer_than_base = or_(
+ excluded.updated_at_epoch > base_updated_at_epoch,
+ failure_from_loaded_row,
+ )
+ conflict_failures = case(
+ (reset_lineage, 1),
+ (
+ failure_is_newer_than_base,
+ func.greatest(
+ HttpBridgeRetryCircuit.consecutive_failures + 1,
+ excluded.consecutive_failures,
+ ),
+ ),
+ else_=HttpBridgeRetryCircuit.consecutive_failures,
+ )
+ merged_updated_at = func.greatest(
+ HttpBridgeRetryCircuit.updated_at_epoch,
+ excluded.updated_at_epoch,
+ )
+ merged_cooldown = case(
+ (reset_lineage, reset_failure_cooldown),
+ (
+ conflict_failures >= threshold,
+ func.greatest(
+ cooldown_floor,
+ merged_updated_at + cooldown_for_failure_count(conflict_failures, excluded.last_detail),
+ ),
+ ),
+ else_=0.0,
+ )
+ statement = insert_statement.on_conflict_do_update(
+ index_elements=[
+ HttpBridgeRetryCircuit.session_key_kind,
+ HttpBridgeRetryCircuit.session_key_hash,
+ HttpBridgeRetryCircuit.api_key_scope,
+ ],
+ set_={
+ "consecutive_failures": conflict_failures,
+ "cooldown_until_epoch": case(
+ (reset_lineage, reset_failure_cooldown),
+ else_=func.greatest(
+ HttpBridgeRetryCircuit.cooldown_until_epoch,
+ excluded.cooldown_until_epoch,
+ merged_cooldown,
+ ),
+ ),
+ "last_detail": case(
+ (reset_lineage, excluded.last_detail),
+ (
+ excluded.updated_at_epoch >= HttpBridgeRetryCircuit.updated_at_epoch,
+ excluded.last_detail,
+ ),
+ else_=HttpBridgeRetryCircuit.last_detail,
+ ),
+ "updated_at_epoch": case(
+ (reset_lineage, excluded.updated_at_epoch),
+ else_=func.greatest(
+ HttpBridgeRetryCircuit.updated_at_epoch,
+ excluded.updated_at_epoch,
+ ),
+ ),
+ },
+ )
+ elif dialect == "sqlite":
+ insert_statement = sqlite_insert(HttpBridgeRetryCircuit).values(**values)
+ excluded = insert_statement.excluded
+ reset_lineage = and_(
+ HttpBridgeRetryCircuit.consecutive_failures == 0,
+ HttpBridgeRetryCircuit.cooldown_until_epoch <= 0,
+ HttpBridgeRetryCircuit.last_detail.is_(None),
+ HttpBridgeRetryCircuit.updated_at_epoch > base_updated_at_epoch,
+ )
+ failure_from_loaded_row = and_(
+ HttpBridgeRetryCircuit.updated_at_epoch == base_updated_at_epoch,
+ excluded.consecutive_failures >= HttpBridgeRetryCircuit.consecutive_failures,
+ )
+ failure_is_newer_than_base = or_(
+ excluded.updated_at_epoch > base_updated_at_epoch,
+ failure_from_loaded_row,
+ )
+ conflict_failures = case(
+ (reset_lineage, 1),
+ (
+ failure_is_newer_than_base,
+ func.max(
+ HttpBridgeRetryCircuit.consecutive_failures + 1,
+ excluded.consecutive_failures,
+ ),
+ ),
+ else_=HttpBridgeRetryCircuit.consecutive_failures,
+ )
+ merged_updated_at = func.max(
+ HttpBridgeRetryCircuit.updated_at_epoch,
+ excluded.updated_at_epoch,
+ )
+ merged_cooldown = case(
+ (reset_lineage, reset_failure_cooldown),
+ (
+ conflict_failures >= threshold,
+ func.max(
+ cooldown_floor,
+ merged_updated_at + cooldown_for_failure_count(conflict_failures, excluded.last_detail),
+ ),
+ ),
+ else_=0.0,
+ )
+ statement = insert_statement.on_conflict_do_update(
+ index_elements=[
+ HttpBridgeRetryCircuit.session_key_kind,
+ HttpBridgeRetryCircuit.session_key_hash,
+ HttpBridgeRetryCircuit.api_key_scope,
+ ],
+ set_={
+ "consecutive_failures": conflict_failures,
+ "cooldown_until_epoch": case(
+ (reset_lineage, reset_failure_cooldown),
+ else_=func.max(
+ HttpBridgeRetryCircuit.cooldown_until_epoch,
+ excluded.cooldown_until_epoch,
+ merged_cooldown,
+ ),
+ ),
+ "last_detail": case(
+ (reset_lineage, excluded.last_detail),
+ (
+ excluded.updated_at_epoch >= HttpBridgeRetryCircuit.updated_at_epoch,
+ excluded.last_detail,
+ ),
+ else_=HttpBridgeRetryCircuit.last_detail,
+ ),
+ "updated_at_epoch": case(
+ (reset_lineage, excluded.updated_at_epoch),
+ else_=func.max(
+ HttpBridgeRetryCircuit.updated_at_epoch,
+ excluded.updated_at_epoch,
+ ),
+ ),
+ },
+ )
+ else:
+ raise RuntimeError(f"DurableBridgeRepository retry circuit upsert unsupported for dialect={dialect!r}")
+ async with sqlite_writer_section():
+ await self._session.execute(statement)
+ await self._session.commit()
+
+ async def delete_retry_circuit(
+ self,
+ *,
+ session_key_kind: str,
+ session_key_value: str,
+ api_key_scope: str,
+ expected_updated_at_epoch: float | None = None,
+ ) -> None:
+ conditions = [
+ HttpBridgeRetryCircuit.session_key_kind == session_key_kind,
+ HttpBridgeRetryCircuit.session_key_hash == durable_bridge_hash(session_key_value),
+ HttpBridgeRetryCircuit.api_key_scope == api_key_scope,
+ ]
+ if expected_updated_at_epoch is not None:
+ conditions.append(HttpBridgeRetryCircuit.updated_at_epoch == expected_updated_at_epoch)
+ async with sqlite_writer_section():
+ await self._session.execute(
+ update(HttpBridgeRetryCircuit)
+ .where(*conditions)
+ .values(
+ consecutive_failures=0,
+ cooldown_until_epoch=0.0,
+ last_detail=None,
+ updated_at_epoch=time.time(),
+ )
+ )
+ await self._session.commit()
+
+ async def purge_retry_circuit(
+ self,
+ *,
+ session_key_kind: str,
+ session_key_value: str,
+ api_key_scope: str,
+ expected_updated_at_epoch: float | None = None,
+ ) -> None:
+ conditions = [
+ HttpBridgeRetryCircuit.session_key_kind == session_key_kind,
+ HttpBridgeRetryCircuit.session_key_hash == durable_bridge_hash(session_key_value),
+ HttpBridgeRetryCircuit.api_key_scope == api_key_scope,
+ ]
+ if expected_updated_at_epoch is not None:
+ conditions.append(HttpBridgeRetryCircuit.updated_at_epoch == expected_updated_at_epoch)
+ async with sqlite_writer_section():
+ await self._session.execute(delete(HttpBridgeRetryCircuit).where(*conditions))
+ await self._session.commit()
+
async def get_session_by_id(self, session_id: str) -> DurableBridgeSessionSnapshot | None:
row = await self._session.get(HttpBridgeSessionRecord, session_id)
return _to_snapshot(row)
@@ -378,6 +693,41 @@ async def renew_session(
values=values,
)
+ async def rebind_session_account(
+ self,
+ *,
+ session_id: str,
+ instance_id: str,
+ owner_epoch: int,
+ account_id: str,
+ clear_continuity: bool = False,
+ ) -> bool:
+ """Persist a replacement account only while this worker owns the lease."""
+
+ async with sqlite_writer_section():
+ values: dict[str, object] = {"account_id": account_id}
+ if clear_continuity:
+ values.update(
+ latest_turn_state=None,
+ latest_response_id=None,
+ latest_input_item_count=None,
+ latest_input_full_fingerprint=None,
+ latest_pending_tool_calls_json=None,
+ )
+ result = await self._session.execute(
+ update(HttpBridgeSessionRecord)
+ .where(
+ HttpBridgeSessionRecord.id == session_id,
+ HttpBridgeSessionRecord.owner_instance_id == instance_id,
+ HttpBridgeSessionRecord.owner_epoch == owner_epoch,
+ )
+ .values(**values)
+ )
+ if clear_continuity and bool(getattr(result, "rowcount", 0)):
+ await self._clear_aliases_for_session(session_id)
+ await self._session.commit()
+ return bool(getattr(result, "rowcount", 0))
+
async def release_session(
self,
*,
@@ -406,6 +756,215 @@ async def release_session(
values=values,
)
+ async def record_recovery_attempt(
+ self,
+ *,
+ session_id: str,
+ instance_id: str,
+ owner_epoch: int,
+ request_fingerprint: str,
+ request_id: str,
+ account_id: str | None,
+ model: str | None,
+ replay_safe: bool,
+ ) -> DurableBridgeRecoveryAttemptSnapshot | None:
+ """Record a safe request before dispatch so an ambiguous outcome is recoverable."""
+ async with sqlite_writer_section():
+ # Lock the owner row through the journal write so a takeover
+ # cannot advance the epoch after this check but before dispatch.
+ owner_exists = await self._session.scalar(
+ select(HttpBridgeSessionRecord.id)
+ .where(
+ HttpBridgeSessionRecord.id == session_id,
+ HttpBridgeSessionRecord.owner_instance_id == instance_id,
+ HttpBridgeSessionRecord.owner_epoch == owner_epoch,
+ )
+ .with_for_update()
+ )
+ if owner_exists is None:
+ await self._session.rollback()
+ return None
+ attempt = await self._session.scalar(
+ select(HttpBridgeRecoveryAttemptRecord)
+ .where(HttpBridgeRecoveryAttemptRecord.session_id == session_id)
+ .where(HttpBridgeRecoveryAttemptRecord.request_fingerprint == request_fingerprint)
+ .with_for_update()
+ )
+ if attempt is None:
+ attempt = HttpBridgeRecoveryAttemptRecord(
+ session_id=session_id,
+ request_fingerprint=request_fingerprint,
+ request_id=request_id,
+ account_id=account_id,
+ model=model,
+ replay_safe=replay_safe,
+ state=HttpBridgeRecoveryAttemptState.UNKNOWN,
+ )
+ self._session.add(attempt)
+ elif attempt.state == HttpBridgeRecoveryAttemptState.REPLAYED:
+ snapshot = _to_recovery_attempt_snapshot(attempt)
+ await self._session.rollback()
+ return snapshot
+ elif attempt.request_id != request_id:
+ # A different request already owns the UNKNOWN checkpoint.
+ # Do not overwrite it while that request may still be between
+ # admission and dispatch; the caller must fail closed rather
+ # than sharing a journal generation.
+ snapshot = _to_recovery_attempt_snapshot(attempt)
+ await self._session.rollback()
+ return snapshot
+ else:
+ attempt.request_id = request_id
+ attempt.account_id = account_id
+ attempt.model = model
+ attempt.replay_safe = replay_safe
+ attempt.state = HttpBridgeRecoveryAttemptState.UNKNOWN
+ attempt.response_id = None
+ try:
+ await self._session.commit()
+ except IntegrityError:
+ # A concurrent owner may have inserted the same fingerprint
+ # after our initial SELECT (the absent-row case cannot be
+ # locked by SQLite). Re-read the winner and use its durable
+ # state instead of surfacing a transient uniqueness failure.
+ await self._session.rollback()
+ owner_exists = await self._session.scalar(
+ select(HttpBridgeSessionRecord.id)
+ .where(
+ HttpBridgeSessionRecord.id == session_id,
+ HttpBridgeSessionRecord.owner_instance_id == instance_id,
+ HttpBridgeSessionRecord.owner_epoch == owner_epoch,
+ )
+ .with_for_update()
+ )
+ if owner_exists is None:
+ await self._session.rollback()
+ return None
+ attempt = await self._session.scalar(
+ select(HttpBridgeRecoveryAttemptRecord)
+ .where(HttpBridgeRecoveryAttemptRecord.session_id == session_id)
+ .where(HttpBridgeRecoveryAttemptRecord.request_fingerprint == request_fingerprint)
+ )
+ if attempt is None:
+ raise
+ if attempt.state == HttpBridgeRecoveryAttemptState.REPLAYED:
+ snapshot = _to_recovery_attempt_snapshot(attempt)
+ await self._session.rollback()
+ return snapshot
+ if attempt.request_id != request_id:
+ snapshot = _to_recovery_attempt_snapshot(attempt)
+ await self._session.rollback()
+ return snapshot
+ attempt.request_id = request_id
+ attempt.account_id = account_id
+ attempt.model = model
+ attempt.replay_safe = replay_safe
+ attempt.state = HttpBridgeRecoveryAttemptState.UNKNOWN
+ attempt.response_id = None
+ await self._session.commit()
+ await self._session.refresh(attempt)
+ return _to_recovery_attempt_snapshot(attempt)
+
+ async def lookup_recovery_attempt(
+ self,
+ *,
+ session_id: str,
+ request_fingerprint: str,
+ ) -> DurableBridgeRecoveryAttemptSnapshot | None:
+ attempt = await self._session.scalar(
+ select(HttpBridgeRecoveryAttemptRecord)
+ .where(HttpBridgeRecoveryAttemptRecord.session_id == session_id)
+ .where(HttpBridgeRecoveryAttemptRecord.request_fingerprint == request_fingerprint)
+ .where(HttpBridgeRecoveryAttemptRecord.state == HttpBridgeRecoveryAttemptState.UNKNOWN)
+ .where(HttpBridgeRecoveryAttemptRecord.replay_safe.is_(True))
+ )
+ return _to_recovery_attempt_snapshot(attempt) if attempt is not None else None
+
+ async def mark_recovery_attempt_replayed(
+ self,
+ *,
+ session_id: str,
+ instance_id: str,
+ owner_epoch: int,
+ request_fingerprint: str,
+ response_id: str | None = None,
+ ) -> bool:
+ async with sqlite_writer_section():
+ # Keep the owner fence and journal transition in one transaction.
+ # PostgreSQL's row lock prevents a concurrent takeover from
+ # advancing the epoch between the check and the state update;
+ # sqlite_writer_section provides the equivalent writer
+ # serialization for SQLite.
+ owner_exists = await self._session.scalar(
+ select(HttpBridgeSessionRecord.id)
+ .where(
+ HttpBridgeSessionRecord.id == session_id,
+ HttpBridgeSessionRecord.owner_instance_id == instance_id,
+ HttpBridgeSessionRecord.owner_epoch == owner_epoch,
+ )
+ .with_for_update()
+ )
+ if owner_exists is None:
+ await self._session.rollback()
+ return False
+ values: dict[str, object] = {"state": HttpBridgeRecoveryAttemptState.REPLAYED}
+ if response_id is not None:
+ values["response_id"] = response_id
+ # A claim authorizes one replay and must only transition UNKNOWN
+ # rows. Settlement (which supplies response_id) remains idempotent
+ # for a REPLAYED row after the replay completes.
+ claimable_states = (
+ (HttpBridgeRecoveryAttemptState.UNKNOWN,)
+ if response_id is None
+ else (HttpBridgeRecoveryAttemptState.UNKNOWN, HttpBridgeRecoveryAttemptState.REPLAYED)
+ )
+ result = await self._session.execute(
+ update(HttpBridgeRecoveryAttemptRecord)
+ .where(
+ HttpBridgeRecoveryAttemptRecord.session_id == session_id,
+ HttpBridgeRecoveryAttemptRecord.request_fingerprint == request_fingerprint,
+ HttpBridgeRecoveryAttemptRecord.state.in_(claimable_states),
+ )
+ .values(**values)
+ )
+ await self._session.commit()
+ return bool(getattr(result, "rowcount", 0))
+
+ async def rollback_recovery_attempt_replayed(
+ self,
+ *,
+ session_id: str,
+ instance_id: str,
+ owner_epoch: int,
+ request_fingerprint: str,
+ ) -> bool:
+ """Return a pre-dispatch replay claim to UNKNOWN under the owner fence."""
+ async with sqlite_writer_section():
+ owner_exists = await self._session.scalar(
+ select(HttpBridgeSessionRecord.id)
+ .where(
+ HttpBridgeSessionRecord.id == session_id,
+ HttpBridgeSessionRecord.owner_instance_id == instance_id,
+ HttpBridgeSessionRecord.owner_epoch == owner_epoch,
+ )
+ .with_for_update()
+ )
+ if owner_exists is None:
+ await self._session.rollback()
+ return False
+ result = await self._session.execute(
+ update(HttpBridgeRecoveryAttemptRecord)
+ .where(
+ HttpBridgeRecoveryAttemptRecord.session_id == session_id,
+ HttpBridgeRecoveryAttemptRecord.request_fingerprint == request_fingerprint,
+ HttpBridgeRecoveryAttemptRecord.state == HttpBridgeRecoveryAttemptState.REPLAYED,
+ HttpBridgeRecoveryAttemptRecord.response_id.is_(None),
+ )
+ .values(state=HttpBridgeRecoveryAttemptState.UNKNOWN)
+ )
+ await self._session.commit()
+ return bool(getattr(result, "rowcount", 0))
+
async def _execute_fenced_session_update(
self,
*,
@@ -639,6 +1198,43 @@ async def purge_abandoned_before(self, cutoff: datetime, *, batch_size: int = _P
await self._session.commit()
deleted_count += len(deleted.scalars().all())
+ async def purge_retry_circuits_before(
+ self,
+ cutoff_epoch: float,
+ *,
+ batch_size: int = _PURGE_CLOSED_BATCH_SIZE,
+ ) -> int:
+ deleted_count = 0
+ while True:
+ result = await self._session.execute(
+ select(
+ HttpBridgeRetryCircuit.session_key_kind,
+ HttpBridgeRetryCircuit.session_key_hash,
+ HttpBridgeRetryCircuit.api_key_scope,
+ )
+ .where(HttpBridgeRetryCircuit.updated_at_epoch < cutoff_epoch)
+ .limit(batch_size)
+ )
+ keys = [tuple(row) for row in result.fetchall()]
+ if not keys:
+ return deleted_count
+ batch_deleted_count = 0
+ async with sqlite_writer_section():
+ for session_key_kind, session_key_hash, api_key_scope in keys:
+ deleted = await self._session.execute(
+ delete(HttpBridgeRetryCircuit)
+ .where(HttpBridgeRetryCircuit.session_key_kind == session_key_kind)
+ .where(HttpBridgeRetryCircuit.session_key_hash == session_key_hash)
+ .where(HttpBridgeRetryCircuit.api_key_scope == api_key_scope)
+ .where(HttpBridgeRetryCircuit.updated_at_epoch < cutoff_epoch)
+ .returning(HttpBridgeRetryCircuit.session_key_hash)
+ )
+ batch_deleted_count += len(deleted.scalars().all())
+ await self._session.commit()
+ if batch_deleted_count == 0:
+ return deleted_count
+ deleted_count += batch_deleted_count
+
async def upsert_alias(
self,
*,
@@ -1075,7 +1671,9 @@ async def missing_durable_bridge_tables(session: AsyncSession) -> tuple[str, ...
result = await session.execute(
text(
"SELECT name FROM sqlite_master "
- "WHERE type = 'table' AND name IN ('http_bridge_sessions', 'http_bridge_session_aliases')"
+ "WHERE type = 'table' "
+ "AND name IN ('http_bridge_sessions', 'http_bridge_session_aliases', 'http_bridge_retry_circuits', "
+ "'http_bridge_recovery_attempts')"
)
)
else:
@@ -1083,7 +1681,10 @@ async def missing_durable_bridge_tables(session: AsyncSession) -> tuple[str, ...
text(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'public' "
- "AND table_name IN ('http_bridge_sessions', 'http_bridge_session_aliases')"
+ "AND table_name IN ("
+ "'http_bridge_sessions', 'http_bridge_session_aliases', 'http_bridge_retry_circuits', "
+ "'http_bridge_recovery_attempts'"
+ ")"
)
)
present = {str(row[0]) for row in result.fetchall()}
@@ -1175,3 +1776,32 @@ def _to_snapshot_required(row: HttpBridgeSessionRecord) -> DurableBridgeSessionS
if snapshot is None:
raise RuntimeError("Expected durable bridge session snapshot")
return snapshot
+
+
+def _to_recovery_attempt_snapshot(
+ row: HttpBridgeRecoveryAttemptRecord,
+) -> DurableBridgeRecoveryAttemptSnapshot:
+ return DurableBridgeRecoveryAttemptSnapshot(
+ session_id=row.session_id,
+ request_fingerprint=row.request_fingerprint,
+ request_id=row.request_id,
+ account_id=row.account_id,
+ model=row.model,
+ replay_safe=bool(row.replay_safe),
+ state=row.state,
+ response_id=row.response_id,
+ )
+
+
+def _to_retry_circuit_snapshot(row: HttpBridgeRetryCircuit | None) -> DurableBridgeRetryCircuitSnapshot | None:
+ if row is None:
+ return None
+ return DurableBridgeRetryCircuitSnapshot(
+ session_key_kind=row.session_key_kind,
+ session_key_hash=row.session_key_hash,
+ api_key_scope=row.api_key_scope,
+ consecutive_failures=row.consecutive_failures,
+ cooldown_until_epoch=row.cooldown_until_epoch,
+ last_detail=row.last_detail,
+ updated_at_epoch=row.updated_at_epoch,
+ )
diff --git a/app/modules/proxy/service.py b/app/modules/proxy/service.py
index b077a794fd..000f2f4b55 100644
--- a/app/modules/proxy/service.py
+++ b/app/modules/proxy/service.py
@@ -139,9 +139,15 @@
from app.modules.proxy._service.http_bridge.helpers import (
_active_http_bridge_instance_ring as _active_http_bridge_instance_ring,
)
+from app.modules.proxy._service.http_bridge.helpers import (
+ _await_cancelled_task as _await_cancelled_task,
+)
from app.modules.proxy._service.http_bridge.helpers import (
_build_http_bridge_prewarm_text as _build_http_bridge_prewarm_text,
)
+from app.modules.proxy._service.http_bridge.helpers import (
+ _cancel_and_track_cancelled_task as _cancel_and_track_cancelled_task,
+)
from app.modules.proxy._service.http_bridge.helpers import (
_durable_bridge_lookup_active_owner as _durable_bridge_lookup_active_owner,
)
@@ -314,6 +320,10 @@
from app.modules.proxy._service.http_bridge.helpers import (
_trim_http_bridge_previous_response_input_items as _trim_http_bridge_previous_response_input_items,
)
+from app.modules.proxy._service.http_bridge.retry_circuit import (
+ _HTTPBridgeRetryCircuitMixin,
+ _initialize_http_bridge_retry_circuit,
+)
from app.modules.proxy._service.observability import _hash_identifier as _hash_identifier
from app.modules.proxy._service.observability import _hash_identifier_or_none as _hash_identifier_or_none
from app.modules.proxy._service.observability import _interesting_header_keys as _interesting_header_keys
@@ -742,9 +752,6 @@
logger = logging.getLogger(__name__)
-
-_TASK_CANCEL_TIMEOUT_SECONDS = 1.0
-_TaskResultT = TypeVar("_TaskResultT")
_ResponsesPayloadT = TypeVar("_ResponsesPayloadT", ResponsesRequest, ResponsesCompactRequest)
_DOWNSTREAM_WEBSOCKET_IDLE_CLOSE_REASON = "Idle downstream websocket timeout"
_DOWNSTREAM_WEBSOCKET_RECEIVE_POLL_SECONDS = 1.0
@@ -783,23 +790,6 @@ def _proxy_admission_wait_timeout_seconds(settings: Any | None = None) -> float:
_STREAM_KEEPALIVE_MAX_COUNT = 6
-async def _await_cancelled_task(
- task: asyncio.Task[_TaskResultT],
- *,
- timeout_seconds: float = _TASK_CANCEL_TIMEOUT_SECONDS,
- label: str,
-) -> bool:
- task.cancel()
- try:
- await asyncio.wait_for(task, timeout=timeout_seconds)
- except asyncio.CancelledError:
- return True
- except TimeoutError:
- logger.warning("Timed out waiting for %s cancellation", label)
- return False
- return True
-
-
_TEXT_DELTA_EVENT_TYPES = frozenset({"response.output_text.delta", "response.refusal.delta"})
_TEXT_DONE_CONTENT_PART_TYPES = frozenset({"output_text", "refusal"})
_REQUEST_TRANSPORT_HTTP = "http"
@@ -814,6 +804,7 @@ async def _await_cancelled_task(
*PERMANENT_FAILURE_CODES.keys(),
}
)
+
_TRANSIENT_RETRY_CODES = frozenset(
{
"overloaded_error",
@@ -924,6 +915,7 @@ class ProxyService(
_CompactMixin,
_StreamingMixin,
_WebSocketMixin,
+ _HTTPBridgeRetryCircuitMixin,
_HTTPBridgeMixin,
):
def __init__(
@@ -941,6 +933,7 @@ def __init__(
self._durable_bridge = DurableBridgeSessionCoordinator(SessionLocal)
self._http_bridge_owner_client = HTTPBridgeOwnerClient()
self._http_bridge_sessions: dict[_HTTPBridgeSessionKey, _HTTPBridgeSession] = {}
+ _initialize_http_bridge_retry_circuit(self)
self._http_bridge_inflight_sessions: dict[_HTTPBridgeSessionKey, asyncio.Future[_HTTPBridgeSession]] = {}
self._http_bridge_turn_state_index: dict[tuple[str, str | None], _HTTPBridgeSessionKey] = {}
self._http_bridge_previous_response_index: dict[tuple[str, str | None], _HTTPBridgeSessionKey] = {}
@@ -1308,6 +1301,7 @@ async def _acquire_request_state_response_create_admission(
pending_request_ids: list[str] | None = None
pending_request_ages_seconds: list[float] | None = None
should_retire_stuck_session = False
+ stale_pending_requests_to_fail: list[_WebSocketRequestState] = []
if bridge_session is not None:
now = time.monotonic()
async with bridge_session.pending_lock:
@@ -1317,25 +1311,46 @@ async def _acquire_request_state_response_create_admission(
pending_request_ids = [state.request_log_id or state.request_id for state in pending_states]
pending_request_ages_seconds = [max(0.0, now - state.started_at) for state in pending_states]
threshold_seconds = float(
- getattr(
- get_settings(),
- "http_responses_session_bridge_stuck_gate_retire_after_seconds",
- 300.0,
- )
+ getattr(get_settings(), "http_responses_session_bridge_stuck_gate_retire_after_seconds", 300.0)
)
- # Leading telemetry records latency without assigning a response
- # or releasing this gate; only response-created proves progress.
- should_retire_stuck_session = any(
- state.transport == _REQUEST_TRANSPORT_HTTP
- and not state.skip_request_log
- and state.response_create_gate_acquired
- and state.awaiting_response_created
- and not state.downstream_visible
- and state.latency_response_created_ms is None
- and state.response_event_count == 0
- and max(0.0, now - state.started_at) >= threshold_seconds
- for state in pending_states
+ stale_pending_requests_to_fail, should_retire_stuck_session = (
+ self._classify_http_bridge_stale_gate_holders(
+ pending_states,
+ now=now,
+ threshold_seconds=threshold_seconds,
+ session_closed=bridge_session.closed,
+ )
)
+ if not should_retire_stuck_session and any(
+ max(0.0, now - state.started_at) >= threshold_seconds for state in pending_states
+ ):
+ # A gate waiter starved past the stuck threshold without the
+ # watchdog firing: dump every pending state's verdict inputs
+ # so the blocking condition is identifiable from prod logs.
+ logger.warning(
+ "http_bridge_stuck_watchdog_skipped session_closed=%s candidates=%s states=%s",
+ bridge_session.closed,
+ len(stale_pending_requests_to_fail),
+ "; ".join(
+ (
+ f"id={state.request_log_id or state.request_id}"
+ f" transport={state.transport}"
+ f" skip_log={state.skip_request_log}"
+ f" draining={state.draining_until_terminal}"
+ f" prev_resp={state.previous_response_id is not None}"
+ f" hard_anchor={state.hard_continuity_anchor}"
+ f" gate_acq={state.response_create_gate_acquired}"
+ f" awaiting={state.awaiting_response_created}"
+ f" resp_id={state.response_id is not None}"
+ f" events={state.response_event_count}"
+ f" created_ms={state.latency_response_created_ms}"
+ f" dsvis={state.downstream_visible}"
+ f" age={max(0.0, now - state.started_at):.0f}"
+ f" gate_wait_age={max(0.0, now - state.response_create_gate_wait_started_at) if state.response_create_gate_wait_started_at is not None else None}" # noqa: E501
+ )
+ for state in pending_states
+ ),
+ )
_log_http_bridge_startup_wait_timeout(
stage="response_create_gate",
timeout_seconds=timeout_seconds,
@@ -1348,7 +1363,13 @@ async def _acquire_request_state_response_create_admission(
pending_request_ids=pending_request_ids,
pending_request_ages_seconds=pending_request_ages_seconds,
)
- if bridge_session is not None and should_retire_stuck_session:
+ if bridge_session is not None and stale_pending_requests_to_fail:
+ await self._fail_stale_http_bridge_pending_requests(
+ bridge_session,
+ stale_pending_requests_to_fail,
+ detail="response_create_gate_timeout_stuck_pending",
+ )
+ elif bridge_session is not None and should_retire_stuck_session:
_record_http_bridge_stuck_retire(
reason="response_create_gate_timeout_stuck_pending",
session=bridge_session,
diff --git a/app/modules/sticky_sessions/cleanup_scheduler.py b/app/modules/sticky_sessions/cleanup_scheduler.py
index e9a795df46..e19d4a1e07 100644
--- a/app/modules/sticky_sessions/cleanup_scheduler.py
+++ b/app/modules/sticky_sessions/cleanup_scheduler.py
@@ -4,6 +4,7 @@
import contextlib
import importlib
import logging
+import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import timedelta
@@ -14,7 +15,11 @@
from app.core.utils.time import utcnow
from app.db.models import DashboardSettings
from app.db.session import SessionLocal, get_background_session
-from app.modules.proxy.durable_bridge_repository import DurableBridgeRepository, missing_durable_bridge_tables
+from app.modules.proxy.durable_bridge_repository import (
+ DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS,
+ DurableBridgeRepository,
+ missing_durable_bridge_tables,
+)
from app.modules.proxy.ring_membership import RING_MEMBER_RETENTION_SECONDS, RingMembershipService
from app.modules.proxy.sticky_repository import StickySessionsRepository
from app.modules.settings.repository import SettingsRepository
@@ -121,6 +126,14 @@ async def _cleanup_as_leader(self) -> None:
logger.info(
"Purged abandoned HTTP bridge sessions deleted_count=%s", abandoned_deleted_count
)
+ retry_circuit_deleted_count = await bridge_repo.purge_retry_circuits_before(
+ time.time() - DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS
+ )
+ if retry_circuit_deleted_count > 0:
+ logger.info(
+ "Purged expired HTTP bridge retry circuits deleted_count=%s",
+ retry_circuit_deleted_count,
+ )
ring_cutoff = utcnow() - timedelta(seconds=RING_MEMBER_RETENTION_SECONDS)
ring_deleted_count = await RingMembershipService(SessionLocal).purge_stale_before(ring_cutoff)
if ring_deleted_count > 0:
diff --git a/docs/reference/settings.md b/docs/reference/settings.md
index 1038b33fd8..cbbf1f855c 100644
--- a/docs/reference/settings.md
+++ b/docs/reference/settings.md
@@ -7,7 +7,7 @@ Regenerate with `uv run python scripts/generate_settings_reference.py`;
`tests/unit/test_settings_reference.py` fails when this page drifts from
`app/core/config/settings.py`.
-codex-lb currently exposes 115 settings. Every setting is an environment
+codex-lb currently exposes 116 settings. Every setting is an environment
variable with the `CODEX_LB_` prefix (process environment or `.env` /
`.env.local` next to the process). All defaults work with zero configuration —
start from [Configuration](../configuration.md) for the handful that matter,
@@ -82,6 +82,7 @@ the host side of the compose `ports` mapping instead.
| Environment variable | Type | Default |
| --- | --- | --- |
| `CODEX_LB_HTTP_RESPONSES_SESSION_BRIDGE_ADVERTISE_BASE_URL` | `str \| None` | `None` |
+| `CODEX_LB_HTTP_RESPONSES_SESSION_BRIDGE_CLEAN_CLOSE_RETRY_JITTER_MAX_SECONDS` | `float` | `2.0` |
| `CODEX_LB_HTTP_RESPONSES_SESSION_BRIDGE_CODEX_IDLE_TTL_SECONDS` | `float` | `900.0` |
| `CODEX_LB_HTTP_RESPONSES_SESSION_BRIDGE_CODEX_PREWARM_ENABLED` | `bool` | `False` |
| `CODEX_LB_HTTP_RESPONSES_SESSION_BRIDGE_ENABLED` | `bool` | `True` |
diff --git a/openspec/changes/recover-codex-desktop-idle-bridge/specs/proxy-admission-control/spec.md b/openspec/changes/recover-codex-desktop-idle-bridge/specs/proxy-admission-control/spec.md
index 51bbccd853..293763926d 100644
--- a/openspec/changes/recover-codex-desktop-idle-bridge/specs/proxy-admission-control/spec.md
+++ b/openspec/changes/recover-codex-desktop-idle-bridge/specs/proxy-admission-control/spec.md
@@ -2,19 +2,20 @@
### Requirement: Stuck HTTP bridge response-create gate sessions are retired
-The proxy MUST retain the existing waiter-triggered retirement behavior for stale HTTP bridge response-create gate owners and MUST additionally enforce an owner-side deadline for a visible HTTP request whose current upstream `response.create` send remains completely eventless before `response.created`. The owner-side deadline MUST be measured from a monotonic timestamp recorded immediately before the current upstream send, MUST use the smaller of the configured stuck-gate retirement threshold and 240 seconds, MUST run without a second gate waiter, and MUST remain active when periodic SSE keepalives are disabled.
+The proxy MUST retain the existing waiter-triggered retirement behavior for stale HTTP bridge response-create gate owners and MUST additionally enforce an owner-side deadline for a visible HTTP request whose current upstream `response.create` send remains completely eventless before `response.created`. The owner-side deadline MUST be measured from a monotonic timestamp recorded immediately before the current upstream send, MUST use the smaller of the configured stuck-gate retirement threshold and 60 seconds, MUST run without a second gate waiter, and MUST remain active when periodic SSE keepalives are disabled.
The owner-side watchdog MUST apply only while the request owns the response-create gate, awaits `response.created`, has neither a response id nor recorded `response.created` latency, has received no matched `response.*` lifecycle event, and has produced no downstream-visible output or sequence evidence. Non-response telemetry such as `codex.rate_limits` MUST NOT suppress this watchdog. Any matched `response.*` lifecycle event, response-created milestone, or downstream-visible evidence MUST suppress the owner-side watchdog and leave existing timeout behavior unchanged.
-When the owner-side deadline expires, the proxy MUST recheck eligibility, emit a structured low-cardinality log and the existing stuck-retirement Prometheus counter, terminally fail and settle every pending request exactly once, and retire the whole bridge session. It MUST NOT transparently replay the timed-out request, move it to another account, or write an account-health failure for the missing-created timeout.
+When the owner-side deadline expires, the proxy MUST recheck eligibility and emit a structured low-cardinality log and the existing stuck-retirement Prometheus counter. For requests that are not eligible for the bounded fresh-hard recovery defined by `recover-fresh-hard-bridge-timeouts`, it MUST terminally fail and settle every pending request exactly once, retire the whole bridge session, and MUST NOT transparently replay the timed-out request or move it to another account. An eligible fresh hard request MAY take that single bounded recovery path; if recovery is unavailable or fails, it MUST fall back to the same terminal fail-closed retirement. Neither path may write an account-health failure solely because `response.created` was missing.
#### Scenario: Lone eventless gate owner is retired before the client timeout
- **GIVEN** a visible HTTP bridge request owns the response-create gate
- **AND** its current `response.create` send produced no matched `response.*` event, response id, or downstream-visible output
- **AND** no second request waits for the gate
-- **WHEN** the smaller of the configured stuck threshold and 240 seconds elapses after the current send
-- **THEN** the proxy emits an explicit terminal failure and retires the bridge session
+- **WHEN** the smaller of the configured stuck threshold and 60 seconds elapses after the current send
+- **THEN** the proxy emits an explicit terminal failure and retires the bridge session when the request is not eligible for bounded fresh-hard recovery
+- **AND** an eligible fresh hard request instead follows the single bounded recovery defined by `recover-fresh-hard-bridge-timeouts`
- **AND** recovery occurs before the native client's 300-second parsed-event idle timeout
#### Scenario: Send time rather than request age anchors the deadline
@@ -43,5 +44,5 @@ When the owner-side deadline expires, the proxy MUST recheck eligibility, emit a
- **GIVEN** an eventless pre-created owner reaches the owner-side deadline
- **WHEN** terminal cleanup runs
- **THEN** every pending request is settled exactly once and the whole session is retired
-- **AND** the proxy does not replay the timed-out request or submit it on another account
+- **AND** the proxy does not replay the timed-out request or submit it on another account unless it satisfies the bounded fresh-hard recovery requirement
- **AND** the selected account is not marked unhealthy solely because `response.created` was missing
diff --git a/openspec/changes/recover-fresh-hard-bridge-timeouts/proposal.md b/openspec/changes/recover-fresh-hard-bridge-timeouts/proposal.md
new file mode 100644
index 0000000000..46dc9567ef
--- /dev/null
+++ b/openspec/changes/recover-fresh-hard-bridge-timeouts/proposal.md
@@ -0,0 +1,29 @@
+## Why
+
+Fresh HTTP Responses bridge requests can remain pinned to a hard session-header
+owner even after repeated pre-response retries prove that the selected upstream
+socket is not acknowledging `response.create`. The retry circuit prevents a
+retry storm, but the eventless watchdog eventually returns a stream error after
+the 240-second safety cap. A self-contained request with no previous-response,
+turn-state, or account-scoped file ownership can safely move to another active
+account without breaking continuity.
+
+## What Changes
+
+- Permit pre-response recovery to exclude the failing account for a fresh,
+ self-contained hard session-header request.
+- Permit a proof-gated client full resend to replay once on its required
+ continuity owner, including when that owner's retry circuit is cooling down.
+- Keep previous-response, turn-state, file-pinned, and proxy-injected anchored
+ requests on their required account.
+- Preserve the existing retry circuit and eventless watchdog as bounded
+ fallbacks when no alternate account is eligible.
+- Add regression coverage proving fresh hard requests can switch accounts while
+ anchored requests remain owner-bound.
+
+## Impact
+
+- Affected capabilities: `proxy-admission-control`, `responses-api-compat`.
+- Fresh requests recover without waiting for the client-safe eventless timeout
+ when another account is available.
+- Continuity-sensitive requests retain their existing fail-closed behavior.
diff --git a/openspec/changes/recover-fresh-hard-bridge-timeouts/specs/proxy-admission-control/spec.md b/openspec/changes/recover-fresh-hard-bridge-timeouts/specs/proxy-admission-control/spec.md
new file mode 100644
index 0000000000..c18e0f8bf5
--- /dev/null
+++ b/openspec/changes/recover-fresh-hard-bridge-timeouts/specs/proxy-admission-control/spec.md
@@ -0,0 +1,74 @@
+# proxy-admission-control Delta
+
+## ADDED Requirements
+
+### Requirement: Fresh hard bridge requests may recover across accounts
+
+When a hard HTTP bridge request is still pre-response and has no
+`previous_response_id`, hard continuity anchor, proxy-injected anchor, or
+account-scoped file ownership, pre-response recovery MAY exclude the failed
+session account and select another eligible account. The request MUST retain
+its original request body and deadline. Requests carrying any of those
+continuity or ownership markers MUST remain pinned to the required account.
+
+#### Scenario: Fresh hard request switches after silent upstream failure
+
+- **GIVEN** a hard session-header request has sent `response.create`
+- **AND** upstream has not emitted `response.created` or any response event
+- **AND** the request has no previous-response, turn-state, proxy-injected
+ anchor, or account-scoped file ownership
+- **WHEN** pre-response recovery retries the request
+- **THEN** the failed account is excluded from selection
+- **AND** another eligible account may receive the unchanged request body
+- **AND** the original request deadline remains in force
+
+#### Scenario: Eventless watchdog gives fresh requests one bounded recovery
+
+- **GIVEN** a hard session-header request has reached the eventless
+ `response.created` watchdog without response events
+- **AND** the request has no previous-response, turn-state, proxy-injected
+ anchor, or account-scoped file ownership
+- **WHEN** the client-safe watchdog deadline expires
+- **THEN** the proxy attempts the same bounded pre-response recovery once
+- **AND** the failed account is excluded when recovery selects a replacement
+- **AND** if recovery is unavailable, the proxy preserves the existing
+ terminal timeout behavior
+
+#### Scenario: Fresh account recovery bypasses a stale retry circuit
+
+- **GIVEN** a hard session key has an active retry cooldown from repeated
+ pre-response failures
+- **AND** the pending request is fresh, self-contained, and has no continuity
+ or account-ownership marker
+- **WHEN** bounded pre-response recovery is attempted
+- **THEN** the request may bypass that cooldown once to exclude the failed
+ account
+- **AND** continuity-bound requests remain subject to the retry cooldown
+
+#### Scenario: Continuity-bound hard request remains pinned
+
+- **GIVEN** a hard request has a previous-response id, continuity anchor,
+ proxy-injected anchor, or account-scoped file ownership
+- **WHEN** pre-response recovery retries the request
+- **THEN** the original account remains required
+- **AND** the request is not replayed through another account
+
+#### Scenario: Proof-gated client full resend replays on the continuity owner
+
+- **GIVEN** a hard request has a previous-response id and a client-provided
+ full resend whose input body has passed the bridge's retry-safety checks
+- **AND** upstream has not emitted `response.created` or any response event
+- **WHEN** bounded pre-response recovery is attempted
+- **THEN** the bridge may strip the previous-response id and replay the verified
+ full body once
+- **AND** recovery remains pinned to the original continuity owner
+- **AND** an unverified continuation remains fail-closed
+
+#### Scenario: Unsafe continuity timeout does not wait through an unusable cooldown
+
+- **GIVEN** a hard continuation has no proof-gated full resend available
+- **AND** the retry circuit is cooling down after repeated pre-response failures
+- **WHEN** the downstream keepalive window expires
+- **THEN** the proxy fails the stream closed immediately
+- **AND** it does not hold the client connection open until the cooldown ends
+- **AND** the client may retry with its continuity payload intact
diff --git a/openspec/changes/recover-fresh-hard-bridge-timeouts/tasks.md b/openspec/changes/recover-fresh-hard-bridge-timeouts/tasks.md
new file mode 100644
index 0000000000..d1326d05f0
--- /dev/null
+++ b/openspec/changes/recover-fresh-hard-bridge-timeouts/tasks.md
@@ -0,0 +1,9 @@
+- [x] Add the delta specification for safe fresh hard-session account recovery.
+- [x] Implement the guarded account exclusion during pre-response recovery.
+- [x] Route eventless watchdog expiry through the bounded recovery path.
+- [x] Allow fresh hard account recovery to bypass a stale cooldown once.
+- [x] Allow proof-gated client full resends to recover on their required owner.
+- [x] Fail unsafe continuity streams promptly instead of waiting through an unusable cooldown.
+- [x] Add regression coverage for fresh and continuity-bound hard requests.
+- [ ] Run focused tests, architecture checks, and strict OpenSpec validation.
+- [ ] Build and canary-test the image before any deployment.
diff --git a/openspec/changes/recover-repeated-clean-close/.openspec.yaml b/openspec/changes/recover-repeated-clean-close/.openspec.yaml
new file mode 100644
index 0000000000..ff5f854ace
--- /dev/null
+++ b/openspec/changes/recover-repeated-clean-close/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-17
diff --git a/openspec/changes/recover-repeated-clean-close/proposal.md b/openspec/changes/recover-repeated-clean-close/proposal.md
new file mode 100644
index 0000000000..04d3093d7e
--- /dev/null
+++ b/openspec/changes/recover-repeated-clean-close/proposal.md
@@ -0,0 +1,47 @@
+## Why
+
+The HTTP Responses bridge currently opens its retry circuit after a clean
+upstream WebSocket close even when the replacement socket also closes before
+producing any response event. A downstream idle-recovery task can also replace
+the upstream socket without restarting its reader. Closing the old socket then
+wakes that stale reader, which misclassifies the proxy-initiated close as an
+upstream failure and retires work already moved to the replacement socket.
+Together these behaviors make a transient handoff issue visible as a reconnect
+loop and require the Codex client to be restarted.
+
+## What Changes
+
+- Permit one additional pre-visible replay when the replacement upstream
+ WebSocket closes cleanly before any response event.
+- Add bounded, configurable jitter before that additional replay to avoid
+ synchronized reconnects.
+- Emit a dedicated diagnostic event for the additional clean-close replay.
+- Keep the allowance hard-capped at one and preserve all existing no-replay
+ behavior after downstream-visible output or continuity-sensitive state.
+- When recovery is initiated outside the upstream reader, cancel and await the
+ old reader before closing its socket, then start exactly one reader for the
+ replacement socket.
+- Keep the shared session live while the replacement socket opens so concurrent
+ idle pruning cannot evict and fail its pending response during the handoff.
+- Start silent pre-response recovery with enough headroom to reconnect before
+ the downstream client's request timeout boundary.
+- Do not let a proxy-initiated close of a superseded socket retire pending work
+ on the replacement socket or increment the retry circuit.
+- Detect a stuck pre-response gate from the absence of upstream activity and
+ response creation, rather than admission flags alone. Give requests with a
+ prior continuity anchor a bounded two-threshold grace period, and emit
+ diagnostic state when the watchdog skips a candidate.
+
+## Impact
+
+- Repeated clean handoffs can recover transparently without an immediate
+ terminal circuit-open response.
+- The retry remains bounded and does not create an unbounded replay loop.
+- Reader ownership follows the active socket across idle recovery, preventing
+ locally generated close frames from being counted as upstream instability.
+- Adds the `http_bridge_retry_circuits` durable table and migration so retry
+ cooldown state survives cross-replica clean-close and incomplete-stream
+ failures.
+- Adds a forward-only request-usage rollup repair migration for deployments
+ already stamped at the previous merge head, so changing migration ancestry
+ cannot leave startup schema-drift checks failing.
diff --git a/openspec/changes/recover-repeated-clean-close/specs/responses-api-compat/spec.md b/openspec/changes/recover-repeated-clean-close/specs/responses-api-compat/spec.md
new file mode 100644
index 0000000000..d825873a65
--- /dev/null
+++ b/openspec/changes/recover-repeated-clean-close/specs/responses-api-compat/spec.md
@@ -0,0 +1,191 @@
+## MODIFIED Requirements
+
+### Requirement: Clean upstream close before any response event fails fast
+
+When the HTTP Responses bridge observes an upstream WebSocket close with
+`close_code = 1000` before any `response.*` event has been surfaced for the
+pending request, the proxy MUST preserve its existing pre-visible replay
+guards. If the request has already used exactly one eligible pre-visible
+replay and the replacement upstream WebSocket also closes cleanly before any
+response event, the proxy MAY perform exactly one additional replay. The
+additional replay MUST be hard-capped at one per request, and the configured
+maximum MUST NOT raise that cap.
+
+The proxy MUST NOT replay after downstream-visible output, after a terminal
+response event, or when continuity-sensitive request state makes replay unsafe.
+Before the additional replay, the proxy MAY sleep for bounded configured
+jitter. The proxy MUST emit a dedicated low-cardinality diagnostic event for
+the additional replay.
+
+When a downstream HTTP stream task initiates pre-response recovery while the
+upstream reader is blocked on the superseded socket, the proxy MUST cancel and
+await that reader before locally closing the socket. It MUST then start exactly
+one reader for the replacement socket. A close caused by replacing the socket
+MUST NOT be recorded as an upstream clean-close failure, MUST NOT increment the
+retry circuit, and MUST NOT retire pending work moved to the replacement. The
+cancelled reader's socket-generation finalizer MUST NOT leave the shared session
+marked closed while the replacement socket is being selected or opened, so idle
+pruning MUST NOT evict the handoff in progress.
+
+The default pre-response idle-recovery window MUST leave bounded headroom
+before the downstream client's request timeout. With the default ten-second
+keepalive interval, the proxy MUST initiate eligible recovery after no more
+than six silent intervals so replacement connection and first output can occur
+before a 120-second client deadline.
+
+The stuck pre-response watchdog MUST judge staleness using elapsed time since
+the last upstream activity and the absence of a response identifier or
+`response.created` latency, not admission flags alone. A request with a prior
+continuity anchor MUST receive at most two retire-thresholds of grace before
+being considered stale. When the watchdog skips a candidate, it MUST emit a
+low-cardinality diagnostic containing the session-closed state, candidate
+count, and pending-state verdicts.
+
+#### Scenario: clean close before response output receives one bounded additional replay
+
+- **GIVEN** an HTTP bridge request has no surfaced `response.*` events
+- **AND** its first pre-visible replay has already been used
+- **WHEN** the replacement upstream WebSocket closes with code `1000`
+- **THEN** the proxy performs one additional pre-visible replay
+- **AND** the request replay count increases by one
+- **AND** the proxy emits a `retry_precreated_clean_close` diagnostic event
+
+#### Scenario: repeated clean closes do not create an unbounded replay loop
+
+- **GIVEN** the additional clean-close replay has already been used
+- **WHEN** another upstream WebSocket closes cleanly before response output
+- **THEN** the proxy does not replay the request again
+- **AND** the existing terminal or circuit handling is used
+
+#### Scenario: visible output still prevents clean-close replay
+
+- **GIVEN** the pending request has surfaced any response event downstream
+- **WHEN** the upstream WebSocket closes with code `1000`
+- **THEN** the proxy does not replay the request
+
+#### Scenario: clean-close retry jitter is bounded
+
+- **GIVEN** clean-close retry jitter is configured
+- **WHEN** the additional clean-close replay is scheduled
+- **THEN** the delay is no greater than the configured jitter maximum
+- **AND** the hard replay cap remains one regardless of the configured value
+
+#### Scenario: downstream idle recovery transfers reader ownership
+
+- **GIVEN** the upstream reader is blocked on the current bridge socket
+- **AND** the downstream HTTP stream task initiates eligible pre-response recovery
+- **WHEN** the bridge replaces the upstream socket
+- **THEN** the old reader is cancelled and awaited before its socket is closed
+- **AND** the shared session remains live while the replacement socket opens
+- **AND** idle pruning retains the registered session while the handoff is in progress
+- **AND** exactly one reader owns the replacement socket
+- **AND** the local close does not open or increment the retry circuit
+- **AND** pending work remains attached to the replacement session
+
+#### Scenario: silent pre-response recovery precedes the client timeout
+
+- **GIVEN** the upstream has produced no response event
+- **AND** the default ten-second keepalive interval is active
+- **WHEN** six silent intervals elapse
+- **THEN** the proxy initiates eligible pre-response recovery
+- **AND** at least sixty seconds remain before a 120-second client request timeout
+
+#### Scenario: anchored stuck-gate grace is bounded
+
+- **GIVEN** a pending HTTP bridge request has a prior continuity anchor
+- **AND** no response identifier or `response.created` latency has been recorded
+- **WHEN** less than two retire thresholds have elapsed since the gate began waiting
+- **THEN** the watchdog does not classify the request as stale
+- **WHEN** two retire thresholds elapse without upstream activity
+- **THEN** the watchdog may classify the request as stale
+
+#### Scenario: upstream activity resolves admission-flag ambiguity
+
+- **GIVEN** a pending request has not acquired the response-created gate
+- **AND** upstream activity has not produced a response identifier or `response.created`
+- **WHEN** the staleness threshold elapses
+- **THEN** the watchdog classifies the request as stale
+- **AND** emits pending-state verdict inputs when it skips a watchdog pass
+
+### Requirement: Durable retry-circuit state protects repeated hard-affinity failures
+
+For a hard-affinity bridge key, the proxy MUST scope retry-circuit state by
+affinity kind, affinity key, and API-key scope (using a stable anonymous scope
+when no API key is present). The proxy MUST record only the documented
+pre-response failure classes (`stream_incomplete`, `clean_close`, and
+`stream_idle_timeout`).
+
+The default circuit MUST open after two consecutive recorded failures. Once
+open, it MUST suppress pre-created replay until the persisted cooldown expires,
+using exponential backoff from sixty seconds up to ten minutes. Clean-close
+failures MUST cap their cooldown at thirty seconds. The proxy MUST persist
+failure count, cooldown deadline, last failure detail, and update time in the
+`http_bridge_retry_circuits` table and MUST merge conflict updates so concurrent
+replicas cannot shorten an existing cooldown.
+
+The clean-close retry jitter maximum MUST be read from the
+`http_responses_session_bridge_clean_close_retry_jitter_max_seconds` runtime
+setting and MUST be bounded to the inclusive range 0–30 seconds.
+
+The proxy MUST evict process-local circuit entries and their loaded/persisted
+markers after one hour without use, independently of durable-row cleanup, so
+one-shot hard-affinity keys cannot grow the worker's memory without bound.
+
+Before every hard-affinity retry decision, the proxy MUST refresh the durable
+row so a cooldown opened by another replica is observed even when this process
+has already loaded the key. A durable lookup or persistence failure MUST NOT
+crash the request; the proxy MUST continue using available local state and
+record the failure for observability. Rows older than one hour MUST be treated
+as expired and removed. A successful terminal response MUST clear the local
+and durable circuit state.
+
+#### Scenario: the second hard-key failure opens a durable circuit
+
+- **GIVEN** a hard-affinity key has one recorded pre-response failure
+- **WHEN** a second eligible failure is recorded
+- **THEN** the proxy opens the retry circuit
+- **AND** persists at least two consecutive failures and a cooldown deadline
+- **AND** subsequent pre-created replay is suppressed until that deadline
+
+#### Scenario: retry decisions observe a cooldown opened by another replica
+
+- **GIVEN** this replica previously looked up a hard-affinity key with no row
+- **AND** another replica persists an open cooldown for that same key and API-key scope
+- **WHEN** this replica evaluates the next pre-created retry
+- **THEN** it refreshes durable state before deciding
+- **AND** suppresses the retry for the persisted cooldown
+
+#### Scenario: circuit state remains isolated by key and API-key scope
+
+- **GIVEN** one hard-affinity key has an open circuit
+- **WHEN** a different affinity key or API-key scope evaluates a retry
+- **THEN** that request is not suppressed by the first key's circuit
+
+#### Scenario: durable circuit lookup failure does not fail the request
+
+- **GIVEN** durable retry-circuit lookup or persistence is unavailable
+- **WHEN** the proxy evaluates or records a retry-circuit event
+- **THEN** the request continues using any available local circuit state
+- **AND** the failure is logged and exposed through retry-circuit observability
+
+### Requirement: Upstream websocket drops penalize affected accounts
+
+When an upstream websocket closes while one or more streamed response requests
+are pending and have not reached a terminal event, the proxy MUST record a
+transient upstream error for the account before signaling failure for those
+pending requests, except when the close carries a classified process-wide
+network failure, is a clean close (`close_code = 1000`) before any
+`response.*` event, or carries the classified per-socket
+`upstream_keepalive_timeout` transport error. Clean pre-response closes and
+keepalive timeouts MUST remain account-neutral while using the bounded retry
+and retry-circuit handling above. A classified process-wide network failure
+MUST remain account neutral and use its network error code. For other closes,
+the proxy MUST surface
+`stream_incomplete` to affected pending requests.
+
+#### Scenario: clean pre-response close does not penalize the account
+
+- **GIVEN** a hard-affinity HTTP bridge request is pending with no surfaced response event
+- **WHEN** the upstream websocket closes cleanly before response output
+- **THEN** the proxy records the clean-close retry-circuit outcome
+- **AND** the selected account is not penalized
diff --git a/openspec/changes/recover-repeated-clean-close/tasks.md b/openspec/changes/recover-repeated-clean-close/tasks.md
new file mode 100644
index 0000000000..aa0246098d
--- /dev/null
+++ b/openspec/changes/recover-repeated-clean-close/tasks.md
@@ -0,0 +1,14 @@
+- [x] Add bounded clean-close replay settings with safe defaults.
+- [x] Allow one additional clean-close replay only before visible output.
+- [x] Add jitter and dedicated retry diagnostics.
+- [x] Add regression coverage for the second replay and retry cap.
+- [x] Restart the upstream reader when pre-response recovery is initiated by the downstream stream task.
+- [x] Add regression coverage for old-reader cancellation and replacement-reader ownership.
+- [x] Keep the shared session live across the cancelled reader's socket-generation finalizer.
+- [x] Add regression coverage for concurrent pruning during reader handoff.
+- [x] Move the default pre-response recovery threshold ahead of the client timeout boundary.
+- [x] Bound anchored stuck-gate grace and evaluate staleness from upstream activity/response creation.
+- [x] Emit stuck-watchdog skip diagnostics with pending-state verdict inputs.
+- [x] Add a forward-only repair for databases stamped before request-usage rollups were connected to the merge head.
+- [x] Validate the OpenSpec change and run the focused and full test suites.
+- [x] Build and deploy the validated image, then verify production health and logs.
diff --git a/openspec/changes/recover-safe-http-bridge-continuations/.openspec.yaml b/openspec/changes/recover-safe-http-bridge-continuations/.openspec.yaml
new file mode 100644
index 0000000000..f205fc727f
--- /dev/null
+++ b/openspec/changes/recover-safe-http-bridge-continuations/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-29
diff --git a/openspec/changes/recover-safe-http-bridge-continuations/proposal.md b/openspec/changes/recover-safe-http-bridge-continuations/proposal.md
new file mode 100644
index 0000000000..1d0c904f2b
--- /dev/null
+++ b/openspec/changes/recover-safe-http-bridge-continuations/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+An HTTP bridge request can lose its upstream acknowledgement after the
+`response.create` send. Retrying a continuation with the same
+`previous_response_id` can fork work or duplicate side effects, while waiting
+through a retry-circuit cooldown only consumes the client request budget.
+
+## What Changes
+
+- Allow one fresh-upstream replay only when the request state contains a
+ proof-gated, unanchored full-resend payload. The proof applies equally to
+ client-provided and proxy-injected anchors.
+- Fail continuity-bound requests closed when a retry-circuit cooldown is
+ active and no safe fresh replay exists.
+- Record proof-gated fresh-resend attempts in a durable recovery journal so a
+ later replica can replay only an unresolved, transport-ambiguous attempt.
+- Keep ordinary requests and existing session ownership on their current
+ recovery paths, and emit the continuity-fail-closed diagnostic for
+ observability.
+
+## Impact
+
+- HTTP bridge continuation recovery and idle-timeout behavior.
+- Adds a durable recovery-attempt journal migration and startup schema check;
+ no public API or account-status changes.
diff --git a/openspec/changes/recover-safe-http-bridge-continuations/specs/responses-api-compat/spec.md b/openspec/changes/recover-safe-http-bridge-continuations/specs/responses-api-compat/spec.md
new file mode 100644
index 0000000000..9fcd9ea000
--- /dev/null
+++ b/openspec/changes/recover-safe-http-bridge-continuations/specs/responses-api-compat/spec.md
@@ -0,0 +1,75 @@
+# responses-api-compat Delta
+
+## ADDED Requirements
+
+### Requirement: Proof-gated recovery attempts are durably fenced
+
+When an HTTP bridge request has a verified, account-neutral, unanchored full
+resend body, the proxy MUST record that request fingerprint in the durable
+recovery journal before dispatching it upstream. The record MUST be owned by
+the current durable session owner epoch and MUST start in `unknown` state.
+Requests without that replay-safety proof MUST NOT create a recovery-journal
+record.
+
+#### Scenario: Safe resend is journaled before dispatch
+
+- **GIVEN** a request has a verified full-resend body that is safe to replay
+ without `previous_response_id`
+- **WHEN** the proxy admits the request for upstream dispatch
+- **THEN** the durable journal contains one `unknown` record for its session
+ and request fingerprint before `response.create` is sent
+
+#### Scenario: Suppressed request is not journaled
+
+- **GIVEN** a hard session retry circuit is cooling down
+- **WHEN** the request is rejected before upstream dispatch
+- **THEN** no recovery-journal record is created or refreshed
+
+### Requirement: Durable replay is limited to ambiguous transport outcomes
+
+The proxy MUST consume an `unknown` recovery-journal record for a fresh
+account-neutral replay only after an ambiguous transport outcome, represented
+by `stream_incomplete`, `stream_idle_timeout`, or
+`upstream_request_timeout`, and only before any response event or downstream
+output. Explicit deterministic `response.failed` errors MUST settle normally
+and MUST NOT trigger a cross-account replay or consume the recovery fence.
+
+#### Scenario: Transport ambiguity permits one replay
+
+- **GIVEN** an `unknown` proof-gated journal record exists
+- **AND** the upstream closes or times out before any response event
+- **WHEN** the bridge handles the ambiguous transport failure
+- **THEN** the record is atomically claimed and the request is replayed once
+ on a fresh account-neutral upstream session
+
+#### Scenario: Deterministic failure is not replayed
+
+- **GIVEN** an `unknown` proof-gated journal record exists
+- **AND** upstream emits an explicit pre-output `response.failed` such as an
+ invalid request or quota rejection
+- **WHEN** the bridge handles that terminal event
+- **THEN** it forwards the terminal failure
+- **AND** it leaves the journal available for settlement without replaying on
+ another account
+
+### Requirement: Recovery journal settlement is owner-fenced and idempotent
+
+After a replayed request reaches `response.completed`, the proxy MUST mark its
+journal record `replayed` only through the current durable owner epoch and
+MUST retain the downstream response id when available. Repeated settlement,
+stale owners, and concurrent claim attempts MUST NOT produce a second replay.
+The migration MUST be on the current Alembic head and startup schema checks
+MUST require the journal table.
+
+#### Scenario: Completed replay settles once
+
+- **GIVEN** a replayed request completes successfully
+- **WHEN** the completion event is processed
+- **THEN** the matching journal record becomes `replayed`
+- **AND** a later retry cannot claim it again
+
+#### Scenario: Stale owner cannot settle or replay
+
+- **GIVEN** a journal record belongs to a newer durable owner epoch
+- **WHEN** an old replica attempts settlement or replay
+- **THEN** the operation is rejected without changing the record state
diff --git a/openspec/changes/recover-safe-http-bridge-continuations/tasks.md b/openspec/changes/recover-safe-http-bridge-continuations/tasks.md
new file mode 100644
index 0000000000..0d6f412bfc
--- /dev/null
+++ b/openspec/changes/recover-safe-http-bridge-continuations/tasks.md
@@ -0,0 +1,12 @@
+## 1. Safe recovery
+
+- [x] Gate fresh-upstream replay on the existing retry-safe full-resend proof,
+ independent of whether the anchor was client-provided or injected.
+- [x] Fail continuity-bound requests closed instead of waiting through an
+ unusable retry-circuit cooldown.
+
+## 2. Verification
+
+- [x] Cover proof-gated replay and unsafe/session-bound replay behavior.
+- [x] Cover continuity-bound classification separately from ordinary requests.
+- [x] Run syntax, architecture, and whitespace validation.
diff --git a/scripts/check_proxy_architecture.py b/scripts/check_proxy_architecture.py
index 580b24bb16..97d0151833 100644
--- a/scripts/check_proxy_architecture.py
+++ b/scripts/check_proxy_architecture.py
@@ -23,9 +23,12 @@
HTTP_BRIDGE_MIXIN_PATH = PROXY_DIR / "_service" / "http_bridge" / "mixin.py"
STREAMING_MIXIN_PATH = PROXY_DIR / "_service" / "streaming" / "mixin.py"
-MAX_SERVICE_LINES = 2_604
+# Ruff 0.16's formatter adds blank lines around top-level definitions. Keep
+# the ratchets at the formatted baseline so whitespace normalization does not
+# consume architectural budget.
+MAX_SERVICE_LINES = 2_617
MAX_LOAD_BALANCER_LINES = 3_260
-MAX_HTTP_BRIDGE_MIXIN_LINES = 2_400
+MAX_HTTP_BRIDGE_MIXIN_LINES = 2_436
MAX_STREAMING_MIXIN_LINES = 1_100
MAX_PROXY_SERVICE_METHOD_LINES = 1_200
MAX_LOAD_BALANCER_SELECT_ACCOUNT_LINES = 699
diff --git a/tests/integration/test_http_responses_bridge.py b/tests/integration/test_http_responses_bridge.py
index 989783c5af..b69f5a6f3b 100644
--- a/tests/integration/test_http_responses_bridge.py
+++ b/tests/integration/test_http_responses_bridge.py
@@ -3895,8 +3895,8 @@ async def blocking_reader_task() -> None:
blocking_reader = asyncio.create_task(blocking_reader_task())
bridge_session.upstream_reader = blocking_reader
- async def fake_await_cancelled_task(task, *, timeout_seconds=1.0, label):
- del task, timeout_seconds, label
+ async def fake_await_cancelled_task(task, *, timeout_seconds=1.0, label, cleanup_tasks=None):
+ del task, timeout_seconds, label, cleanup_tasks
return False
monkeypatch.setattr(proxy_module, "_await_cancelled_task", fake_await_cancelled_task)
@@ -11729,6 +11729,112 @@ async def fake_connect_responses_websocket(
assert connect_count == 1
+@pytest.mark.asyncio
+async def test_v1_responses_http_bridge_idle_recovery_hands_reader_to_replacement(
+ async_client,
+ app_instance,
+ monkeypatch,
+):
+ app_settings = _make_app_settings(enabled=True)
+ app_settings.sse_keepalive_interval_seconds = 0.01
+ _install_proxy_settings(
+ monkeypatch,
+ app_settings=app_settings,
+ dashboard_settings=_make_dashboard_settings(),
+ )
+ monkeypatch.setattr(proxy_module, "_HTTP_BRIDGE_STARTUP_KEEPALIVE_GRACE_SECONDS", 0.01)
+ monkeypatch.setattr(proxy_module, "_STREAM_KEEPALIVE_MAX_COUNT", 1)
+ account_id = await _import_account(
+ async_client,
+ "acc_http_bridge_reader_handoff",
+ "http-bridge-reader-handoff@example.com",
+ )
+ account = await _get_account(account_id)
+ upstreams = [_SilentUpstreamWebSocket(), _FakeBridgeUpstreamWebSocket()]
+ connect_count = 0
+
+ async def fake_select_account_with_budget(
+ self,
+ deadline,
+ *,
+ request_id,
+ kind,
+ request_stage="first_turn",
+ sticky_key,
+ sticky_kind,
+ reallocate_sticky,
+ sticky_max_age_seconds,
+ prefer_earlier_reset_accounts,
+ routing_strategy,
+ model,
+ exclude_account_ids=None,
+ additional_limit_name=None,
+ api_key=None,
+ preferred_account_id=None,
+ **_kwargs,
+ ):
+ del (
+ self,
+ deadline,
+ request_id,
+ kind,
+ request_stage,
+ sticky_key,
+ sticky_kind,
+ reallocate_sticky,
+ sticky_max_age_seconds,
+ prefer_earlier_reset_accounts,
+ routing_strategy,
+ model,
+ exclude_account_ids,
+ additional_limit_name,
+ api_key,
+ preferred_account_id,
+ )
+ return AccountSelection(account=account, error_message=None, error_code=None)
+
+ async def fake_ensure_fresh_with_budget(self, target, *, force=False, timeout_seconds):
+ del self, force, timeout_seconds
+ return target
+
+ async def fake_connect_responses_websocket(
+ headers,
+ access_token,
+ account_id_header,
+ *,
+ base_url=None,
+ session=None,
+ ):
+ del headers, access_token, account_id_header, base_url, session
+ nonlocal connect_count
+ upstream = upstreams[connect_count]
+ connect_count += 1
+ return upstream
+
+ monkeypatch.setattr(proxy_module.ProxyService, "_select_account_with_budget", fake_select_account_with_budget)
+ monkeypatch.setattr(proxy_module.ProxyService, "_ensure_fresh_with_budget", fake_ensure_fresh_with_budget)
+ monkeypatch.setattr(proxy_module, "connect_responses_websocket", fake_connect_responses_websocket)
+ service = get_proxy_service_for_app(app_instance)
+ record_retry_circuit_failure = AsyncMock(wraps=service._record_http_bridge_retry_circuit_failure)
+ monkeypatch.setattr(service, "_record_http_bridge_retry_circuit_failure", record_retry_circuit_failure)
+
+ response = await async_client.post(
+ "/v1/responses",
+ json={
+ "model": "gpt-5.1",
+ "instructions": "Return exactly OK.",
+ "input": "recover-reader-handoff",
+ "prompt_cache_key": "reader-handoff-key",
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.json()["output"][0]["content"][0]["text"] == "OK"
+ assert connect_count == 2
+ assert upstreams[0].closed is True
+ record_retry_circuit_failure.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_retry_http_bridge_precreated_request_releases_pending_lock_before_reconnect(app_instance, monkeypatch):
service = get_proxy_service_for_app(app_instance)
diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py
index 2d82cf4d01..53e77d0a6c 100644
--- a/tests/integration/test_migrations.py
+++ b/tests/integration/test_migrations.py
@@ -1396,3 +1396,70 @@ def _schema_state(sync_conn):
assert survivor == 3
finally:
await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_stamped_merge_rollup_repair_downgrade_preserves_schema(tmp_path):
+ from alembic import command
+ from sqlalchemy import inspect as sa_inspect
+
+ from app.db.migrate import _build_alembic_config
+
+ db_url = f"sqlite+aiosqlite:///{tmp_path / 'stamped-merge-rollup-repair.sqlite'}"
+ merge_revision = "20260724_000000_merge_request_log_schema_heads"
+ final_revision = "20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads"
+ rollup_tables = {
+ "request_usage_hourly_rollups",
+ "request_usage_hourly_error_rollups",
+ "request_demand_quarter_rollups",
+ }
+
+ await to_thread.run_sync(lambda: command.stamp(_build_alembic_config(db_url), merge_revision))
+ # The stamped deployment path already has this durable bridge table. Keep
+ # the fixture representative so the pending-tool-call migration can alter
+ # it just as it does on a real deployed database.
+ engine = create_async_engine(db_url, future=True)
+ try:
+ async with engine.begin() as conn:
+ await conn.execute(
+ text(
+ """
+ CREATE TABLE http_bridge_sessions (
+ id VARCHAR(36) PRIMARY KEY,
+ session_key_kind VARCHAR(64) NOT NULL,
+ session_key_value TEXT NOT NULL,
+ session_key_hash VARCHAR(64) NOT NULL,
+ api_key_scope VARCHAR(255) NOT NULL,
+ owner_instance_id VARCHAR(255),
+ owner_epoch INTEGER NOT NULL DEFAULT 0,
+ lease_expires_at DATETIME,
+ state VARCHAR(16) NOT NULL DEFAULT 'active',
+ account_id VARCHAR,
+ model VARCHAR,
+ service_tier VARCHAR,
+ latest_turn_state TEXT,
+ latest_response_id TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ closed_at DATETIME
+ )
+ """
+ )
+ )
+ finally:
+ await engine.dispose()
+ await to_thread.run_sync(lambda: run_upgrade(db_url, final_revision, bootstrap_legacy=False))
+
+ engine = create_async_engine(db_url, future=True)
+ try:
+ async with engine.connect() as conn:
+ tables = await conn.run_sync(lambda sync_conn: set(sa_inspect(sync_conn).get_table_names()))
+ assert rollup_tables <= tables
+
+ await to_thread.run_sync(lambda: command.downgrade(_build_alembic_config(db_url), merge_revision))
+ async with engine.connect() as conn:
+ tables_after_downgrade = await conn.run_sync(lambda sync_conn: set(sa_inspect(sync_conn).get_table_names()))
+ assert rollup_tables <= tables_after_downgrade
+ finally:
+ await engine.dispose()
diff --git a/tests/unit/test_bridge_context_blowup.py b/tests/unit/test_bridge_context_blowup.py
index eea73d12be..c106a628f2 100644
--- a/tests/unit/test_bridge_context_blowup.py
+++ b/tests/unit/test_bridge_context_blowup.py
@@ -228,7 +228,14 @@ async def failing_send(*args, **kwargs):
f"If this is 400, the bug is present: the CLI will drop "
f"previous_response_id and resend full conversation (70K tok/turn)."
)
- assert exc_info.value.payload["error"]["code"] in ("upstream_unavailable", "bridge_owner_unreachable")
+ # A raw send failure is surfaced with the more specific
+ # ``stream_incomplete`` code; it is still a retriable 502 and keeps
+ # previous_response_id intact for the client retry.
+ assert exc_info.value.payload["error"]["code"] in (
+ "upstream_unavailable",
+ "bridge_owner_unreachable",
+ "stream_incomplete",
+ )
assert "previous_response_not_found" not in str(exc_info.value.payload)
diff --git a/tests/unit/test_bridge_ring_lifecycle.py b/tests/unit/test_bridge_ring_lifecycle.py
index a17587b646..c65929d0da 100644
--- a/tests/unit/test_bridge_ring_lifecycle.py
+++ b/tests/unit/test_bridge_ring_lifecycle.py
@@ -22,6 +22,7 @@
AccountStatus,
Base,
BridgeRingMember,
+ HttpBridgeRetryCircuit,
HttpBridgeSessionAlias,
HttpBridgeSessionRecord,
HttpBridgeSessionState,
@@ -34,6 +35,7 @@
from app.modules.proxy.durable_bridge_repository import (
DurableBridgeAliasRegistration,
DurableBridgeRepository,
+ durable_bridge_hash,
)
from app.modules.proxy.ring_membership import RingMembershipService
@@ -306,6 +308,266 @@ async def test_get_sessions_by_ids_chunks_large_id_sets(
await session.close()
+@pytest.mark.asyncio
+async def test_retry_circuit_upsert_counts_concurrent_failure_conflicts(
+ async_session_factory: Callable[[], AsyncSession],
+) -> None:
+ session = async_session_factory()
+ try:
+ repository = DurableBridgeRepository(session)
+
+ for updated_at in (1000.0, 1001.0):
+ await repository.upsert_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-conflict",
+ api_key_scope="key-1",
+ consecutive_failures=1,
+ cooldown_until_epoch=0.0,
+ last_detail="clean_close",
+ updated_at_epoch=updated_at,
+ failure_threshold=2,
+ conflict_cooldown_until_epoch=2000.0,
+ )
+
+ row = await session.get(
+ HttpBridgeRetryCircuit,
+ (
+ "session_header",
+ durable_bridge_hash("sid-retry-conflict"),
+ "key-1",
+ ),
+ )
+ assert row is not None
+ assert row.consecutive_failures == 2
+ assert row.cooldown_until_epoch == 2000.0
+ assert row.last_detail == "clean_close"
+ assert row.updated_at_epoch == 1001.0
+ finally:
+ await session.close()
+
+
+@pytest.mark.asyncio
+async def test_retry_circuit_conflict_cooldown_scales_with_merged_failures(
+ async_session_factory: Callable[[], AsyncSession],
+) -> None:
+ session = async_session_factory()
+ try:
+ repository = DurableBridgeRepository(session)
+ for failures, updated_at in ((1, 1000.0), (1, 1001.0), (2, 1002.0)):
+ await repository.upsert_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-backoff-conflict",
+ api_key_scope="key-1",
+ consecutive_failures=failures,
+ cooldown_until_epoch=0.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=updated_at,
+ failure_threshold=2,
+ conflict_cooldown_until_epoch=updated_at + 60.0,
+ )
+
+ row = await session.get(
+ HttpBridgeRetryCircuit,
+ (
+ "session_header",
+ durable_bridge_hash("sid-retry-backoff-conflict"),
+ "key-1",
+ ),
+ )
+ assert row is not None
+ assert row.consecutive_failures == 3
+ assert row.cooldown_until_epoch >= 1122.0
+ finally:
+ await session.close()
+
+
+@pytest.mark.asyncio
+async def test_retry_circuit_ignores_out_of_order_failure_snapshot(
+ async_session_factory: Callable[[], AsyncSession],
+) -> None:
+ session = async_session_factory()
+ try:
+ repository = DurableBridgeRepository(session)
+ for failures, updated_at, base_updated_at in (
+ (1, 1000.0, 0.0),
+ (2, 1002.0, 1000.0),
+ (1, 1001.0, 1002.0),
+ ):
+ await repository.upsert_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-out-of-order",
+ api_key_scope="key-1",
+ consecutive_failures=failures,
+ cooldown_until_epoch=0.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=updated_at,
+ base_updated_at_epoch=base_updated_at,
+ failure_threshold=2,
+ conflict_cooldown_until_epoch=updated_at + 60.0,
+ )
+
+ row = await session.get(
+ HttpBridgeRetryCircuit,
+ (
+ "session_header",
+ durable_bridge_hash("sid-retry-out-of-order"),
+ "key-1",
+ ),
+ )
+ assert row is not None
+ assert row.consecutive_failures == 2
+ assert row.updated_at_epoch == 1002.0
+ finally:
+ await session.close()
+
+
+@pytest.mark.asyncio
+async def test_retry_circuit_merges_lagging_wall_clock_failure_from_loaded_base(
+ async_session_factory: Callable[[], AsyncSession],
+) -> None:
+ session = async_session_factory()
+ try:
+ repository = DurableBridgeRepository(session)
+ await repository.upsert_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-clock-skew",
+ api_key_scope="key-1",
+ consecutive_failures=1,
+ cooldown_until_epoch=0.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=2000.0,
+ base_updated_at_epoch=0.0,
+ failure_threshold=2,
+ conflict_cooldown_until_epoch=2060.0,
+ )
+
+ # This replica loaded the row at 2000, then its wall clock lagged the
+ # writer that persisted it. The unchanged loaded row is a CAS match,
+ # so the failure must still open the shared circuit.
+ await repository.upsert_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-clock-skew",
+ api_key_scope="key-1",
+ consecutive_failures=1,
+ cooldown_until_epoch=0.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=1500.0,
+ base_updated_at_epoch=2000.0,
+ failure_threshold=2,
+ conflict_cooldown_until_epoch=1560.0,
+ )
+
+ row = await session.get(
+ HttpBridgeRetryCircuit,
+ (
+ "session_header",
+ durable_bridge_hash("sid-retry-clock-skew"),
+ "key-1",
+ ),
+ )
+ assert row is not None
+ assert row.consecutive_failures == 2
+ assert row.cooldown_until_epoch >= 2060.0
+ assert row.updated_at_epoch == 2000.0
+ finally:
+ await session.close()
+
+
+@pytest.mark.asyncio
+async def test_retry_circuit_reset_starts_new_failure_lineage(
+ async_session_factory: Callable[[], AsyncSession],
+) -> None:
+ session = async_session_factory()
+ try:
+ repository = DurableBridgeRepository(session)
+ await repository.upsert_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-reset-lineage",
+ api_key_scope="key-1",
+ consecutive_failures=3,
+ cooldown_until_epoch=2000.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=1000.0,
+ )
+
+ await repository.delete_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-reset-lineage",
+ api_key_scope="key-1",
+ )
+ await repository.upsert_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-reset-lineage",
+ api_key_scope="key-1",
+ consecutive_failures=1,
+ cooldown_until_epoch=5000.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=2000.0,
+ base_updated_at_epoch=1000.0,
+ )
+
+ row = await session.get(
+ HttpBridgeRetryCircuit,
+ (
+ "session_header",
+ durable_bridge_hash("sid-retry-reset-lineage"),
+ "key-1",
+ ),
+ )
+ assert row is not None
+ assert row.cooldown_until_epoch == 0.0
+ assert row.consecutive_failures == 1
+ assert row.last_detail == "stream_incomplete"
+ assert row.updated_at_epoch == 2000.0
+ finally:
+ await session.close()
+
+
+@pytest.mark.asyncio
+async def test_recovery_attempt_pre_dispatch_claim_can_be_rolled_back(
+ async_session_factory: Callable[[], AsyncSession],
+) -> None:
+ session = async_session_factory()
+ try:
+ repository = DurableBridgeRepository(session)
+ claim = await _claim(
+ repository,
+ instance_id="inst-recovery-rollback",
+ session_key_value="sid-recovery-rollback",
+ )
+ attempt = await repository.record_recovery_attempt(
+ session_id=claim.id,
+ instance_id="inst-recovery-rollback",
+ owner_epoch=claim.owner_epoch,
+ request_fingerprint="fingerprint-recovery-rollback",
+ request_id="request-recovery-rollback",
+ account_id=None,
+ model="gpt-5.4",
+ replay_safe=True,
+ )
+ assert attempt is not None
+ assert await repository.mark_recovery_attempt_replayed(
+ session_id=claim.id,
+ instance_id="inst-recovery-rollback",
+ owner_epoch=claim.owner_epoch,
+ request_fingerprint="fingerprint-recovery-rollback",
+ )
+ assert await repository.rollback_recovery_attempt_replayed(
+ session_id=claim.id,
+ instance_id="inst-recovery-rollback",
+ owner_epoch=claim.owner_epoch,
+ request_fingerprint="fingerprint-recovery-rollback",
+ )
+ restored = await repository.lookup_recovery_attempt(
+ session_id=claim.id,
+ request_fingerprint="fingerprint-recovery-rollback",
+ )
+ assert restored is not None
+ assert restored.state.value == "unknown"
+ finally:
+ await session.close()
+
+
@pytest.mark.asyncio
async def test_ring_purge_removes_dead_members_and_keeps_recent(
async_session_factory: Callable[[], AsyncSession],
diff --git a/tests/unit/test_db_migrate.py b/tests/unit/test_db_migrate.py
index 03a5fc23ed..f33537f08b 100644
--- a/tests/unit/test_db_migrate.py
+++ b/tests/unit/test_db_migrate.py
@@ -2030,7 +2030,10 @@ def test_capability_lineage_migration_is_additive_reversible_and_single_head(tmp
run_upgrade(url, parent_revision, bootstrap_legacy=False)
config = _build_alembic_config(url)
script_directory = ScriptDirectory.from_config(config)
- assert script_directory.get_heads() == [target_revision]
+ heads = script_directory.get_heads()
+ assert len(heads) == 1
+ ancestry = {script.revision for script in script_directory.walk_revisions()}
+ assert target_revision in ancestry
engine = create_engine(to_sync_database_url(url))
try:
diff --git a/tests/unit/test_durable_bridge_sessions.py b/tests/unit/test_durable_bridge_sessions.py
index 7d52d9376d..7eb8ec51c2 100644
--- a/tests/unit/test_durable_bridge_sessions.py
+++ b/tests/unit/test_durable_bridge_sessions.py
@@ -2652,3 +2652,43 @@ async def execute_and_claim_after_candidate_select(statement, *args, **kwargs):
select(HttpBridgeSessionAlias).where(HttpBridgeSessionAlias.session_id == "sid-race-claim")
)
assert aliases.scalar_one_or_none() is not None
+
+
+@pytest.mark.asyncio
+async def test_durable_bridge_retry_circuit_round_trip(
+ coordinator: DurableBridgeSessionCoordinator,
+) -> None:
+ await coordinator.persist_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-circuit",
+ api_key_id="key-1",
+ consecutive_failures=3,
+ cooldown_until_epoch=1234.5,
+ last_detail="stream_incomplete",
+ updated_at_epoch=1200.0,
+ )
+
+ persisted = await coordinator.lookup_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-circuit",
+ api_key_id="key-1",
+ )
+ assert persisted is not None
+ assert persisted.consecutive_failures == 3
+ assert persisted.cooldown_until_epoch == 1234.5
+ assert persisted.last_detail == "stream_incomplete"
+
+ await coordinator.clear_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-circuit",
+ api_key_id="key-1",
+ )
+ cleared = await coordinator.lookup_retry_circuit(
+ session_key_kind="session_header",
+ session_key_value="sid-retry-circuit",
+ api_key_id="key-1",
+ )
+ assert cleared is not None
+ assert cleared.consecutive_failures == 0
+ assert cleared.cooldown_until_epoch == 0.0
+ assert cleared.last_detail is None
diff --git a/tests/unit/test_http_bridge_safe_continuity.py b/tests/unit/test_http_bridge_safe_continuity.py
new file mode 100644
index 0000000000..29ce429c1a
--- /dev/null
+++ b/tests/unit/test_http_bridge_safe_continuity.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from dataclasses import replace
+
+from app.modules.proxy import service as proxy_service
+from app.modules.proxy._service.http_bridge import streaming as http_bridge_streaming_module
+
+
+def test_http_bridge_continuity_bound_without_safe_replay() -> None:
+ unsafe_continuation = proxy_service._WebSocketRequestState(
+ request_id="req-unsafe",
+ model="gpt-5.4",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=1.0,
+ previous_response_id="resp-prev",
+ )
+ safe_full_resend = replace(
+ unsafe_continuation,
+ fresh_upstream_request_text='{"type":"response.create","input":"full"}',
+ fresh_upstream_request_is_retry_safe=True,
+ )
+ hard_turn_state = replace(
+ unsafe_continuation,
+ previous_response_id=None,
+ hard_continuity_anchor=True,
+ )
+ ordinary_request = replace(unsafe_continuation, previous_response_id=None)
+
+ assert http_bridge_streaming_module._http_bridge_continuity_bound_without_safe_replay(unsafe_continuation)
+ assert not http_bridge_streaming_module._http_bridge_continuity_bound_without_safe_replay(safe_full_resend)
+ assert http_bridge_streaming_module._http_bridge_continuity_bound_without_safe_replay(hard_turn_state)
+ assert not http_bridge_streaming_module._http_bridge_continuity_bound_without_safe_replay(ordinary_request)
diff --git a/tests/unit/test_proxy_errors.py b/tests/unit/test_proxy_errors.py
index f55c22c9f6..083d6b437f 100644
--- a/tests/unit/test_proxy_errors.py
+++ b/tests/unit/test_proxy_errors.py
@@ -46,6 +46,36 @@ async def stream():
assert message in events[0]
+@pytest.mark.asyncio
+async def test_stream_proxy_error_preserves_retry_after_as_sse_retry_hint():
+ async def stream():
+ if False:
+ yield ""
+ raise ProxyResponseError(
+ 503,
+ {
+ "error": {
+ "code": "upstream_request_timeout",
+ "message": "Retry shortly.",
+ "type": "server_error",
+ }
+ },
+ retry_after_seconds=2,
+ )
+
+ events = [
+ event
+ async for event in _stream_response_error_events(
+ stream(),
+ owns_reservation=False,
+ reservation=None,
+ )
+ ]
+
+ assert len(events) == 1
+ assert events[0].startswith("retry: 2000\n")
+
+
def _payload_error_code(payload) -> str | None:
return payload["error"].get("code")
diff --git a/tests/unit/test_proxy_http_bridge.py b/tests/unit/test_proxy_http_bridge.py
index 00dd232e8e..ea1f88615f 100644
--- a/tests/unit/test_proxy_http_bridge.py
+++ b/tests/unit/test_proxy_http_bridge.py
@@ -39,6 +39,7 @@
from app.modules.proxy._service.http_bridge import helpers as http_bridge_helpers_module
from app.modules.proxy._service.http_bridge import mixin as http_bridge_mixin_module
from app.modules.proxy._service.http_bridge import request_submit as http_bridge_request_submit_module
+from app.modules.proxy._service.http_bridge import retry_circuit as http_bridge_retry_circuit_module
from app.modules.proxy._service.http_bridge import streaming as http_bridge_streaming_module
from app.modules.proxy._service.http_bridge import upstream_events as http_bridge_upstream_events_module
from app.modules.proxy.account_cache import clear_account_routing_unavailable, mark_account_routing_unavailable
@@ -46,6 +47,7 @@
is_http_bridge_account_neutral_replay,
make_http_bridge_account_neutral_replay_key,
)
+from app.modules.proxy.durable_bridge_coordinator import DurableBridgeSessionCoordinator
from app.modules.proxy.durable_bridge_repository import (
DurableBridgeAliasRegistration,
DurableBridgeAliasRegistrationReceipt,
@@ -65,6 +67,17 @@ async def get(self) -> object:
monkeypatch.setattr(proxy_service, "get_settings_cache", lambda: _SettingsCache())
+@pytest.fixture(autouse=True)
+def _stub_recovery_attempt_lookup(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Keep unit bridge tests independent of the durable recovery schema."""
+
+ monkeypatch.setattr(
+ DurableBridgeSessionCoordinator,
+ "lookup_recovery_attempt",
+ AsyncMock(return_value=None),
+ )
+
+
def _without_installation_metadata(text: str) -> dict[str, Any]:
payload = json.loads(text)
client_metadata = payload.get("client_metadata")
@@ -136,7 +149,7 @@ def test_http_bridge_eventless_precreated_deadline_uses_current_send_and_client_
request_state,
stuck_gate_retire_after_seconds=300.0,
)
- == 340.0
+ == 160.0
)
assert (
http_bridge_helpers_module._http_bridge_eventless_precreated_deadline(
@@ -147,12 +160,13 @@ def test_http_bridge_eventless_precreated_deadline_uses_current_send_and_client_
)
request_state.latency_first_upstream_event_ms = 25
+ request_state.last_upstream_activity_at = 150.0
assert (
http_bridge_helpers_module._http_bridge_eventless_precreated_deadline(
request_state,
stuck_gate_retire_after_seconds=300.0,
)
- == 340.0
+ == 160.0
)
@@ -1262,7 +1276,7 @@ async def test_http_bridge_activity_snapshot_counts_closed_admission_waiter_as_r
@pytest.mark.asyncio
-async def test_response_create_gate_timeout_retires_session_with_old_pending_visible_request(
+async def test_response_create_gate_timeout_retires_old_pending_without_upstream_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = _make_app_settings(
@@ -1284,7 +1298,9 @@ async def test_response_create_gate_timeout_retires_session_with_old_pending_vis
transport="http",
response_create_gate_acquired=True,
awaiting_response_created=True,
- downstream_visible=False,
+ # A downstream keepalive is not evidence that upstream created a
+ # response; this request is still safe to retire after the stale window.
+ downstream_visible=True,
)
waiter = proxy_service._WebSocketRequestState(
request_id="req-visible-waiter",
@@ -1332,6 +1348,110 @@ async def fake_retire(
assert waiter.response_create_gate_acquired is False
+def test_stale_gate_cleanup_keeps_draining_sibling_active() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ now = time.monotonic()
+ stale = proxy_service._WebSocketRequestState(
+ request_id="req-stale-gate-holder",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=now - 301.0,
+ transport="http",
+ )
+ draining = proxy_service._WebSocketRequestState(
+ request_id="req-draining-terminal-sibling",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=now - 301.0,
+ transport="http",
+ draining_until_terminal=True,
+ )
+
+ stale_states, should_retire = service._classify_http_bridge_stale_gate_holders(
+ [stale, draining],
+ now=now,
+ threshold_seconds=300.0,
+ session_closed=False,
+ )
+
+ assert stale_states == [stale]
+ assert should_retire is False
+
+
+@pytest.mark.asyncio
+async def test_response_create_gate_timeout_retires_closed_anchored_pending_without_upstream_event(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ settings = _make_app_settings(
+ proxy_admission_wait_timeout_seconds=0.001,
+ http_responses_session_bridge_stuck_gate_retire_after_seconds=300.0,
+ )
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
+ service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
+ session = _make_bridge_session()
+ session.closed = True
+ service._http_bridge_sessions[session.key] = session
+ await session.response_create_gate.acquire()
+ old_pending = proxy_service._WebSocketRequestState(
+ request_id="req-closed-anchored-pending",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic() - 301.0,
+ transport="http",
+ previous_response_id="resp-anchored",
+ hard_continuity_anchor=True,
+ response_create_gate_acquired=True,
+ awaiting_response_created=True,
+ )
+ waiter = proxy_service._WebSocketRequestState(
+ request_id="req-closed-anchored-waiter",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ transport="http",
+ downstream_visible=True,
+ )
+ async with session.pending_lock:
+ session.pending_requests.append(old_pending)
+ session.queued_request_count = 1
+
+ retire_calls: list[str] = []
+
+ async def fake_retire(
+ retire_session: proxy_service._HTTPBridgeSession,
+ *,
+ detail: str,
+ ) -> None:
+ retire_calls.append(detail)
+ retire_session.closed = True
+
+ monkeypatch.setattr(service, "_retire_stale_pending_http_bridge_session", fake_retire)
+
+ try:
+ with pytest.raises(ProxyResponseError) as exc_info:
+ await service._acquire_request_state_response_create_admission(
+ waiter,
+ response_create_gate=session.response_create_gate,
+ account_id=session.account.id,
+ surface="http_bridge",
+ bridge_session=session,
+ )
+ finally:
+ if session.response_create_gate.locked():
+ session.response_create_gate.release()
+
+ assert exc_info.value.payload["error"]["code"] == "response_create_gate_timeout"
+ assert retire_calls == ["response_create_gate_timeout_stuck_pending"]
+
+
@pytest.mark.asyncio
async def test_response_create_gate_timeout_retires_old_precreated_request_after_rate_limit_telemetry(
monkeypatch: pytest.MonkeyPatch,
@@ -1433,7 +1553,6 @@ async def fake_retire(
),
[
(False, True, 100, 100, 1),
- (True, False, 25, None, 1),
],
)
async def test_response_create_gate_timeout_does_not_retire_active_response_progress(
@@ -1468,6 +1587,17 @@ async def test_response_create_gate_timeout_does_not_retire_active_response_prog
latency_response_created_ms=latency_response_created_ms,
response_event_count=response_event_count,
)
+ stale_pending = proxy_service._WebSocketRequestState(
+ request_id="req-stale-unanchored",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic() - 301.0,
+ transport="http",
+ response_create_gate_acquired=True,
+ awaiting_response_created=True,
+ )
waiter = proxy_service._WebSocketRequestState(
request_id="req-visible-waiter",
model="gpt-5.2",
@@ -1480,7 +1610,8 @@ async def test_response_create_gate_timeout_does_not_retire_active_response_prog
)
async with session.pending_lock:
session.pending_requests.append(active_stream)
- session.queued_request_count = 1
+ session.pending_requests.append(stale_pending)
+ session.queued_request_count = 2
retire_calls: list[str] = []
@@ -1512,99 +1643,352 @@ async def fake_retire(
assert session.closed is False
-@pytest.mark.asyncio
-async def test_http_bridge_activity_snapshot_counts_only_bridge_cleanup_tasks():
- service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
- bridge_task = asyncio.create_task(asyncio.sleep(60), name="proxy-http_bridge_session_close-test")
- api_key_task = asyncio.create_task(asyncio.sleep(60), name="proxy-stream-api-key-settle-test")
- service._background_cleanup_tasks.update({bridge_task, api_key_task})
-
- try:
- snapshot = service.http_bridge_activity_snapshot_nowait()
- finally:
- bridge_task.cancel()
- api_key_task.cancel()
- await asyncio.gather(bridge_task, api_key_task, return_exceptions=True)
-
- assert snapshot["http_bridge_background_cleanup_tasks"] == 1
+def test_http_bridge_pending_state_with_recent_events_but_no_created_is_not_stale() -> None:
+ # Reattached streams can deliver events whose response.created was lost
+ # (observed events=54, created=None in prod on 2026-07-20). Recent upstream
+ # activity must keep the stream alive while the create gate remains held.
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-events-no-created",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic() - 301.0,
+ transport="http",
+ response_create_gate_acquired=True,
+ awaiting_response_created=True,
+ latency_first_upstream_event_ms=25,
+ response_event_count=54,
+ last_upstream_activity_at=time.monotonic(),
+ )
+ assert (
+ http_bridge_helpers_module._http_bridge_pending_state_is_stale(
+ request_state,
+ now=time.monotonic(),
+ threshold_seconds=300.0,
+ )
+ is False
+ )
-@pytest.mark.asyncio
-async def test_http_bridge_activity_snapshot_cleans_completed_stale_inflight_session(
- monkeypatch: pytest.MonkeyPatch,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
- key = proxy_service._HTTPBridgeSessionKey("session_header", "stale-inflight-drain-status", None)
- inflight_future: asyncio.Future[proxy_service._HTTPBridgeSession] = asyncio.get_running_loop().create_future()
- setattr(inflight_future, "_codex_lb_started_at", -1000.0)
- inflight_future.set_result(_make_bridge_session())
- service._http_bridge_inflight_sessions[key] = inflight_future
+ request_state.last_upstream_activity_at = time.monotonic() - 301.0
+ assert (
+ http_bridge_helpers_module._http_bridge_pending_state_is_stale(
+ request_state,
+ now=time.monotonic(),
+ threshold_seconds=300.0,
+ )
+ is True
+ )
- monkeypatch.setattr(proxy_service, "_proxy_admission_wait_timeout_seconds", lambda settings=None: 0.001)
- with caplog.at_level(logging.WARNING, logger="app.modules.proxy.service"):
- snapshot = service.http_bridge_activity_snapshot_nowait()
+def test_http_bridge_pending_state_with_first_event_latency_only_is_stale() -> None:
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-sparse-active",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic() - 301.0,
+ transport="http",
+ response_create_gate_acquired=True,
+ awaiting_response_created=True,
+ latency_first_upstream_event_ms=10,
+ downstream_visible=False,
+ )
- assert key not in service._http_bridge_inflight_sessions
- assert snapshot["http_bridge_inflight_session_creates"] == 0
- assert snapshot["http_bridge_stale_inflight_session_creates"] == 1
- assert snapshot["http_bridge_cleaned_inflight_session_creates"] == 1
- assert snapshot["http_bridge_active"] is False
- assert snapshot["http_bridge_restart_blocking"] is False
- assert "http_bridge_inflight_session_create_cleanup" in caplog.text
+ assert (
+ http_bridge_helpers_module._http_bridge_pending_state_is_stale(
+ request_state,
+ now=time.monotonic(),
+ threshold_seconds=300.0,
+ )
+ is True
+ )
-@pytest.mark.asyncio
-async def test_http_bridge_activity_snapshot_does_not_expire_live_inflight_session(
- monkeypatch: pytest.MonkeyPatch,
+@pytest.mark.parametrize(
+ ("previous_response_id", "session_id", "hard_continuity_anchor"),
+ [
+ ("resp-anchored", None, False),
+ (None, "turn-anchored", True),
+ ],
+)
+def test_http_bridge_pending_state_with_continuity_anchor_is_not_stale(
+ previous_response_id: str | None,
+ session_id: str | None,
+ hard_continuity_anchor: bool,
) -> None:
- service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
- key = proxy_service._HTTPBridgeSessionKey("session_header", "live-stale-inflight-drain-status", None)
- inflight_future: asyncio.Future[proxy_service._HTTPBridgeSession] = asyncio.get_running_loop().create_future()
- setattr(inflight_future, "_codex_lb_started_at", -1000.0)
- service._http_bridge_inflight_sessions[key] = inflight_future
-
- monkeypatch.setattr(proxy_service, "_proxy_admission_wait_timeout_seconds", lambda settings=None: 0.001)
-
- snapshot = service.http_bridge_activity_snapshot_nowait()
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-anchored-pending",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic() - 301.0,
+ transport="http",
+ response_create_gate_acquired=True,
+ awaiting_response_created=True,
+ previous_response_id=previous_response_id,
+ session_id=session_id,
+ hard_continuity_anchor=hard_continuity_anchor,
+ )
- assert key in service._http_bridge_inflight_sessions
- assert not inflight_future.done()
- assert snapshot["http_bridge_inflight_session_creates"] == 1
- assert snapshot["http_bridge_stale_inflight_session_creates"] == 1
- assert snapshot["http_bridge_cleaned_inflight_session_creates"] == 0
- assert snapshot["http_bridge_active"] is True
- assert snapshot["http_bridge_restart_blocking"] is True
+ assert (
+ http_bridge_helpers_module._http_bridge_pending_state_is_stale(
+ request_state,
+ now=time.monotonic(),
+ threshold_seconds=300.0,
+ )
+ is False
+ )
-@pytest.mark.asyncio
-async def test_http_bridge_activity_snapshot_skips_inflight_cleanup_when_registry_locked(
- monkeypatch: pytest.MonkeyPatch,
+@pytest.mark.parametrize(
+ ("previous_response_id", "session_id", "hard_continuity_anchor"),
+ [
+ ("resp-anchored-silent", None, False),
+ (None, "turn-anchored-silent", True),
+ ],
+)
+def test_http_bridge_pending_state_with_continuity_anchor_is_stale_after_extended_silence(
+ previous_response_id: str | None,
+ session_id: str | None,
+ hard_continuity_anchor: bool,
) -> None:
- service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
- key = proxy_service._HTTPBridgeSessionKey("session_header", "locked-stale-inflight-drain-status", None)
- inflight_future: asyncio.Future[proxy_service._HTTPBridgeSession] = asyncio.get_running_loop().create_future()
- setattr(inflight_future, "_codex_lb_started_at", -1000.0)
- service._http_bridge_inflight_sessions[key] = inflight_future
-
- monkeypatch.setattr(proxy_service, "_proxy_admission_wait_timeout_seconds", lambda settings=None: 0.001)
-
- async with service._http_bridge_lock:
- snapshot = service.http_bridge_activity_snapshot_nowait()
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-anchored-silent-pending",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic() - 601.0,
+ transport="http",
+ response_create_gate_acquired=True,
+ awaiting_response_created=True,
+ previous_response_id=previous_response_id,
+ session_id=session_id,
+ hard_continuity_anchor=hard_continuity_anchor,
+ )
- assert key in service._http_bridge_inflight_sessions
- assert not inflight_future.done()
- assert snapshot["http_bridge_inflight_session_creates"] == 1
- assert snapshot["http_bridge_stale_inflight_session_creates"] == 1
- assert snapshot["http_bridge_cleaned_inflight_session_creates"] == 0
- assert snapshot["http_bridge_active"] is True
- assert snapshot["http_bridge_restart_blocking"] is True
+ assert (
+ http_bridge_helpers_module._http_bridge_pending_state_is_stale(
+ request_state,
+ now=time.monotonic(),
+ threshold_seconds=300.0,
+ )
+ is True
+ )
-async def _wait_for_close_await(close_session: AsyncMock, session: proxy_service._HTTPBridgeSession) -> None:
- for _ in range(10):
- if any(call.args == (session,) for call in close_session.await_args_list):
+@pytest.mark.parametrize(
+ ("previous_response_id", "session_id", "hard_continuity_anchor"),
+ [
+ ("resp-closed-anchored", None, False),
+ (None, "turn-closed-anchored", True),
+ ],
+)
+def test_http_bridge_closed_session_pending_anchor_is_stale(
+ previous_response_id: str | None,
+ session_id: str | None,
+ hard_continuity_anchor: bool,
+) -> None:
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-closed-anchored-pending",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic() - 301.0,
+ transport="http",
+ response_create_gate_acquired=True,
+ awaiting_response_created=True,
+ previous_response_id=previous_response_id,
+ session_id=session_id,
+ hard_continuity_anchor=hard_continuity_anchor,
+ )
+
+ assert (
+ http_bridge_helpers_module._http_bridge_pending_state_is_stale(
+ request_state,
+ now=time.monotonic(),
+ threshold_seconds=300.0,
+ session_closed=True,
+ )
+ is True
+ )
+
+
+def test_http_bridge_pending_state_with_plain_session_header_is_stale() -> None:
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-session-header-pending",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic() - 301.0,
+ transport="http",
+ session_id="session-header-only",
+ response_create_gate_acquired=True,
+ awaiting_response_created=True,
+ )
+
+ assert (
+ http_bridge_helpers_module._http_bridge_pending_state_is_stale(
+ request_state,
+ now=time.monotonic(),
+ threshold_seconds=300.0,
+ )
+ is True
+ )
+
+
+def test_http_bridge_synthesized_downstream_turn_state_is_not_hard_anchor() -> None:
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-synth-turn-state",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ transport="http",
+ )
+
+ http_bridge_streaming_module._apply_http_bridge_downstream_turn_state(
+ request_state,
+ downstream_turn_state="synthesized-turn-state",
+ incoming_turn_state_header=None,
+ )
+
+ assert request_state.session_id == "synthesized-turn-state"
+ assert request_state.hard_continuity_anchor is False
+
+
+@pytest.mark.parametrize(
+ ("incoming_turn_state_header", "previous_response_id"),
+ [
+ ("client-turn-state", None),
+ (None, "resp-continuation"),
+ ],
+)
+def test_http_bridge_real_continuity_sets_hard_anchor(
+ incoming_turn_state_header: str | None,
+ previous_response_id: str | None,
+) -> None:
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-real-anchor",
+ model="gpt-5.2",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ transport="http",
+ previous_response_id=previous_response_id,
+ )
+
+ http_bridge_streaming_module._apply_http_bridge_downstream_turn_state(
+ request_state,
+ downstream_turn_state="real-turn-state",
+ incoming_turn_state_header=incoming_turn_state_header,
+ )
+
+ assert request_state.session_id == "real-turn-state"
+ assert request_state.hard_continuity_anchor is True
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_activity_snapshot_counts_only_bridge_cleanup_tasks():
+ service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
+ bridge_task = asyncio.create_task(asyncio.sleep(60), name="proxy-http_bridge_session_close-test")
+ api_key_task = asyncio.create_task(asyncio.sleep(60), name="proxy-stream-api-key-settle-test")
+ service._background_cleanup_tasks.update({bridge_task, api_key_task})
+
+ try:
+ snapshot = service.http_bridge_activity_snapshot_nowait()
+ finally:
+ bridge_task.cancel()
+ api_key_task.cancel()
+ await asyncio.gather(bridge_task, api_key_task, return_exceptions=True)
+
+ assert snapshot["http_bridge_background_cleanup_tasks"] == 1
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_activity_snapshot_cleans_completed_stale_inflight_session(
+ monkeypatch: pytest.MonkeyPatch,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
+ key = proxy_service._HTTPBridgeSessionKey("session_header", "stale-inflight-drain-status", None)
+ inflight_future: asyncio.Future[proxy_service._HTTPBridgeSession] = asyncio.get_running_loop().create_future()
+ setattr(inflight_future, "_codex_lb_started_at", -1000.0)
+ inflight_future.set_result(_make_bridge_session())
+ service._http_bridge_inflight_sessions[key] = inflight_future
+
+ monkeypatch.setattr(proxy_service, "_proxy_admission_wait_timeout_seconds", lambda settings=None: 0.001)
+
+ with caplog.at_level(logging.WARNING, logger="app.modules.proxy.service"):
+ snapshot = service.http_bridge_activity_snapshot_nowait()
+
+ assert key not in service._http_bridge_inflight_sessions
+ assert snapshot["http_bridge_inflight_session_creates"] == 0
+ assert snapshot["http_bridge_stale_inflight_session_creates"] == 1
+ assert snapshot["http_bridge_cleaned_inflight_session_creates"] == 1
+ assert snapshot["http_bridge_active"] is False
+ assert snapshot["http_bridge_restart_blocking"] is False
+ assert "http_bridge_inflight_session_create_cleanup" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_activity_snapshot_does_not_expire_live_inflight_session(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
+ key = proxy_service._HTTPBridgeSessionKey("session_header", "live-stale-inflight-drain-status", None)
+ inflight_future: asyncio.Future[proxy_service._HTTPBridgeSession] = asyncio.get_running_loop().create_future()
+ setattr(inflight_future, "_codex_lb_started_at", -1000.0)
+ service._http_bridge_inflight_sessions[key] = inflight_future
+
+ monkeypatch.setattr(proxy_service, "_proxy_admission_wait_timeout_seconds", lambda settings=None: 0.001)
+
+ snapshot = service.http_bridge_activity_snapshot_nowait()
+
+ assert key in service._http_bridge_inflight_sessions
+ assert not inflight_future.done()
+ assert snapshot["http_bridge_inflight_session_creates"] == 1
+ assert snapshot["http_bridge_stale_inflight_session_creates"] == 1
+ assert snapshot["http_bridge_cleaned_inflight_session_creates"] == 0
+ assert snapshot["http_bridge_active"] is True
+ assert snapshot["http_bridge_restart_blocking"] is True
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_activity_snapshot_skips_inflight_cleanup_when_registry_locked(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, SimpleNamespace()))
+ key = proxy_service._HTTPBridgeSessionKey("session_header", "locked-stale-inflight-drain-status", None)
+ inflight_future: asyncio.Future[proxy_service._HTTPBridgeSession] = asyncio.get_running_loop().create_future()
+ setattr(inflight_future, "_codex_lb_started_at", -1000.0)
+ service._http_bridge_inflight_sessions[key] = inflight_future
+
+ monkeypatch.setattr(proxy_service, "_proxy_admission_wait_timeout_seconds", lambda settings=None: 0.001)
+
+ async with service._http_bridge_lock:
+ snapshot = service.http_bridge_activity_snapshot_nowait()
+
+ assert key in service._http_bridge_inflight_sessions
+ assert not inflight_future.done()
+ assert snapshot["http_bridge_inflight_session_creates"] == 1
+ assert snapshot["http_bridge_stale_inflight_session_creates"] == 1
+ assert snapshot["http_bridge_cleaned_inflight_session_creates"] == 0
+ assert snapshot["http_bridge_active"] is True
+ assert snapshot["http_bridge_restart_blocking"] is True
+
+
+async def _wait_for_close_await(close_session: AsyncMock, session: proxy_service._HTTPBridgeSession) -> None:
+ for _ in range(10):
+ if any(call.args == (session,) for call in close_session.await_args_list):
return
await asyncio.sleep(0)
raise AssertionError("expected HTTP bridge session close to be awaited")
@@ -3226,6 +3610,11 @@ async def test_http_bridge_precreated_completed_terminal_falls_back_to_unresolve
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ monkeypatch.setattr(
+ http_bridge_retry_circuit_module,
+ "_HTTP_BRIDGE_RETRY_CIRCUIT_FAILURE_THRESHOLD",
+ 1,
+ )
finalize = AsyncMock()
register_previous = AsyncMock()
monkeypatch.setattr(service, "_finalize_websocket_request_state", finalize)
@@ -3241,13 +3630,15 @@ async def test_http_bridge_precreated_completed_terminal_falls_back_to_unresolve
awaiting_response_created=True,
event_queue=asyncio.Queue(),
transport="http",
- skip_request_log=True,
)
session = _make_bridge_session(
key_value="bridge-precreated-completed",
pending_requests=deque([request_state]),
queued_request_count=1,
)
+ await service._record_http_bridge_retry_circuit_failure(session, detail="stream_incomplete")
+ retry_circuits = cast(Any, service)._http_bridge_retry_circuits
+ assert session.key in retry_circuits
await service._process_http_bridge_upstream_text(
session,
@@ -3296,6 +3687,7 @@ async def test_http_bridge_precreated_completed_terminal_falls_back_to_unresolve
assert session.last_completed_response_id == "resp_precreated_completed"
assert session.queued_request_count == 0
assert not session.pending_requests
+ assert session.key not in retry_circuits
register_previous.assert_awaited_once()
finalize.assert_awaited_once()
@@ -3756,6 +4148,93 @@ async def fake_submit_http_bridge_request(
assert error["code"] == "stream_incomplete"
+@pytest.mark.asyncio
+async def test_http_bridge_startup_cooldown_releases_api_key_reservation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ session = _make_bridge_session(key_value="sid-startup-reservation")
+ reservation = cast(Any, object())
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-startup-reservation",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=reservation,
+ started_at=time.monotonic(),
+ event_queue=asyncio.Queue(),
+ transport="http",
+ previous_response_id="resp-anchor",
+ )
+ cooldown = AsyncMock(return_value=30.0)
+ release = AsyncMock()
+ monkeypatch.setattr(service, "_http_bridge_precreated_retry_cooldown_seconds", cooldown)
+ monkeypatch.setattr(service, "_release_websocket_request_state_reservation", release)
+
+ events = [
+ event
+ async for event in service._stream_http_bridge_session_events(
+ session,
+ request_state=request_state,
+ text_data='{"type":"response.create"}',
+ queue_limit=8,
+ propagate_http_errors=False,
+ downstream_turn_state=None,
+ )
+ ]
+
+ assert len(events) == 1
+ assert '"code":"stream_idle_timeout"' in events[0]
+ release.assert_awaited_once_with(request_state)
+ assert request_state.api_key_reservation is None
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_post_submit_cooldown_race_detaches_request(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ session = _make_bridge_session(key_value="sid-post-submit-cooldown")
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-post-submit-cooldown",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ event_queue=asyncio.Queue(),
+ transport="http",
+ previous_response_id="resp-anchor",
+ )
+
+ async def submit(target_session: Any, *, request_state: Any, **kwargs: Any) -> None:
+ del kwargs
+ target_session.pending_requests.append(request_state)
+
+ cooldown = AsyncMock(side_effect=[0.0, 30.0])
+ detach = AsyncMock()
+ monkeypatch.setattr(service, "_submit_http_bridge_request", submit)
+ monkeypatch.setattr(service, "_http_bridge_precreated_retry_cooldown_seconds", cooldown)
+ monkeypatch.setattr(service, "_detach_http_bridge_request", detach)
+
+ events = [
+ event
+ async for event in service._stream_http_bridge_session_events(
+ session,
+ request_state=request_state,
+ text_data='{"type":"response.create"}',
+ queue_limit=8,
+ propagate_http_errors=False,
+ downstream_turn_state=None,
+ )
+ ]
+
+ assert len(events) == 1
+ assert '"code":"stream_idle_timeout"' in events[0]
+ assert cooldown.await_count == 2
+ detach.assert_awaited_once_with(session, request_state=request_state)
+
+
@pytest.mark.asyncio
async def test_http_bridge_keepalive_counts_as_first_yield_before_late_response_failed(
monkeypatch: pytest.MonkeyPatch,
@@ -3927,37 +4406,36 @@ async def fake_submit_http_bridge_request(
@pytest.mark.asyncio
-async def test_http_bridge_capacity_wait_with_response_id_sends_explicit_keepalive(
+async def test_http_bridge_idle_recovery_transport_failure_yields_terminal_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = proxy_service.ProxyService(cast(Any, nullcontext()))
- monkeypatch.setattr(service, "_detach_http_bridge_request", AsyncMock())
+ detach = AsyncMock()
+ monkeypatch.setattr(service, "_detach_http_bridge_request", detach)
monkeypatch.setattr(
proxy_service,
"get_settings",
lambda: SimpleNamespace(
+ http_responses_stream_request_budget_seconds=60.0,
sse_keepalive_interval_seconds=0.001,
stream_idle_timeout_seconds=0.001,
),
)
monkeypatch.setattr(proxy_service, "_HTTP_BRIDGE_STARTUP_KEEPALIVE_GRACE_SECONDS", 0.001)
+ monkeypatch.setattr(http_bridge_streaming_module, "_stream_keepalive_max_count", lambda: 1)
- session = _make_bridge_session(key_value="sid-capacity-response")
+ session = _make_bridge_session(key_value="sid-idle-retry-transport")
request_state = proxy_service._WebSocketRequestState(
- request_id="req-capacity-response",
+ request_id="req-idle-retry-transport",
model="gpt-5.1",
service_tier=None,
reasoning_effort=None,
api_key_reservation=None,
started_at=time.monotonic(),
event_queue=asyncio.Queue(),
- response_id="resp-capacity-response",
+ request_text='{"type":"response.create","model":"gpt-5.1","input":"hello"}',
transport="http",
)
- request_state.account_capacity_waiting = True
- request_state.account_capacity_wait_reason = "Rate limit exceeded. Try again in 120s"
- request_state.account_capacity_wait_started_at = time.monotonic() - 3.0
- request_state.account_capacity_wait_retry_after_seconds = 120.0
async def fake_submit_http_bridge_request(
target_session: proxy_service._HTTPBridgeSession,
@@ -3969,13 +4447,90 @@ async def fake_submit_http_bridge_request(
del text_data, queue_limit
target_session.pending_requests.append(request_state)
+ retry_error = UpstreamWebSocketTransportError(
+ "Codex upstream websocket send failed: OSError",
+ error_code="proxy_network_unavailable",
+ )
+ retry_precreated = AsyncMock(side_effect=retry_error)
monkeypatch.setattr(service, "_submit_http_bridge_request", fake_submit_http_bridge_request)
+ monkeypatch.setattr(service, "_retry_http_bridge_precreated_request", retry_precreated)
- stream = service._stream_http_bridge_session_events(
- session,
- request_state=request_state,
- text_data="{}",
- queue_limit=8,
+ chunks = [
+ chunk
+ async for chunk in service._stream_http_bridge_session_events(
+ session,
+ request_state=request_state,
+ text_data="{}",
+ queue_limit=8,
+ propagate_http_errors=True,
+ downstream_turn_state=None,
+ )
+ ]
+
+ assert len(chunks) == 1
+ payload = proxy_service.parse_sse_data_json(chunks[0])
+ assert payload is not None
+ assert payload["type"] == "response.failed"
+ response = payload["response"]
+ assert isinstance(response, dict)
+ error = response["error"]
+ assert isinstance(error, dict)
+ assert error["code"] == "proxy_network_unavailable"
+ assert error["message"] == "Codex upstream websocket send failed: OSError"
+ retry_precreated.assert_awaited_once_with(session, restart_reader=True)
+ detach.assert_awaited_once_with(session, request_state=request_state)
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_capacity_wait_with_response_id_sends_explicit_keepalive(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ monkeypatch.setattr(service, "_detach_http_bridge_request", AsyncMock())
+ monkeypatch.setattr(
+ proxy_service,
+ "get_settings",
+ lambda: SimpleNamespace(
+ sse_keepalive_interval_seconds=0.001,
+ stream_idle_timeout_seconds=0.001,
+ ),
+ )
+ monkeypatch.setattr(proxy_service, "_HTTP_BRIDGE_STARTUP_KEEPALIVE_GRACE_SECONDS", 0.001)
+
+ session = _make_bridge_session(key_value="sid-capacity-response")
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-capacity-response",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ event_queue=asyncio.Queue(),
+ response_id="resp-capacity-response",
+ transport="http",
+ )
+ request_state.account_capacity_waiting = True
+ request_state.account_capacity_wait_reason = "Rate limit exceeded. Try again in 120s"
+ request_state.account_capacity_wait_started_at = time.monotonic() - 3.0
+ request_state.account_capacity_wait_retry_after_seconds = 120.0
+
+ async def fake_submit_http_bridge_request(
+ target_session: proxy_service._HTTPBridgeSession,
+ *,
+ request_state: proxy_service._WebSocketRequestState,
+ text_data: str,
+ queue_limit: int,
+ ) -> None:
+ del text_data, queue_limit
+ target_session.pending_requests.append(request_state)
+
+ monkeypatch.setattr(service, "_submit_http_bridge_request", fake_submit_http_bridge_request)
+
+ stream = service._stream_http_bridge_session_events(
+ session,
+ request_state=request_state,
+ text_data="{}",
+ queue_limit=8,
propagate_http_errors=True,
downstream_turn_state=None,
)
@@ -4085,7 +4640,7 @@ async def test_get_or_create_http_bridge_session_preserves_closed_admission_hand
@pytest.mark.asyncio
-async def test_get_or_create_http_bridge_session_rejects_incompatible_closed_admission_handoff(
+async def test_get_or_create_http_bridge_session_rejects_anchored_incompatible_closed_admission_handoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = proxy_service.ProxyService(cast(Any, nullcontext()))
@@ -4109,6 +4664,7 @@ async def test_get_or_create_http_bridge_session_rejects_incompatible_closed_adm
idle_ttl_seconds=120.0,
max_sessions=8,
preferred_account_id="different-account",
+ previous_response_id="resp-anchored",
)
assert exc_info.value.status_code == 503
@@ -4116,6 +4672,54 @@ async def test_get_or_create_http_bridge_session_rejects_incompatible_closed_adm
create.assert_not_awaited()
+@pytest.mark.asyncio
+async def test_get_or_create_http_bridge_session_recovers_unanchored_closed_admission_handoff(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ key = proxy_service._HTTPBridgeSessionKey("session_header", "bridge-handoff", None)
+ existing = _make_bridge_session(key_value="bridge-handoff")
+ existing.key = key
+ existing.closed = True
+ existing.admission_waiter_count = 1
+ service._http_bridge_sessions[key] = existing
+ replacement = _make_bridge_session(key_value="bridge-handoff")
+ replacement.key = key
+ settings = _make_app_settings()
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
+ monkeypatch.setattr(
+ proxy_service,
+ "_http_bridge_owner_instance",
+ AsyncMock(return_value=settings.http_responses_session_bridge_instance_id),
+ )
+ monkeypatch.setattr(
+ http_bridge_mixin_module,
+ "_http_bridge_owner_instance",
+ AsyncMock(return_value=settings.http_responses_session_bridge_instance_id),
+ )
+ monkeypatch.setattr(http_bridge_mixin_module, "_http_bridge_owner_check_required", lambda *args, **kwargs: False)
+ monkeypatch.setattr(service, "_claim_durable_http_bridge_session", AsyncMock())
+ monkeypatch.setattr(service, "_schedule_http_bridge_session_closes", Mock())
+ create = AsyncMock(return_value=replacement)
+ monkeypatch.setattr(service, "_create_http_bridge_session", create)
+
+ resolved = await service._get_or_create_http_bridge_session(
+ key,
+ headers={"x-codex-session-id": "bridge-handoff"},
+ affinity=proxy_service._AffinityPolicy(key="bridge-handoff"),
+ api_key=None,
+ request_model="gpt-5.4",
+ idle_ttl_seconds=120.0,
+ max_sessions=8,
+ preferred_account_id="different-account",
+ )
+
+ assert resolved is replacement
+ assert service._http_bridge_sessions[key] is replacement
+ assert existing.closed is True
+ create.assert_awaited_once()
+
+
@pytest.mark.asyncio
async def test_get_or_create_http_bridge_session_replaces_routing_unavailable_account(
monkeypatch: pytest.MonkeyPatch,
@@ -6005,6 +6609,156 @@ async def open_upstream(_account: object, headers: dict[str, str], **_: object)
assert "x-handshake-debug" not in forwarded
+@pytest.mark.asyncio
+async def test_reconnect_keeps_handoff_protected_during_lease_swap(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ session = _make_bridge_session()
+ old_lease = proxy_service.AccountLease(
+ lease_id="lease-old-handoff",
+ account_id=session.account.id,
+ kind="stream",
+ acquired_at=1.0,
+ )
+ new_account = cast(Any, SimpleNamespace(id="acc-replacement", status=AccountStatus.ACTIVE, plan_type="plus"))
+ new_lease = proxy_service.AccountLease(
+ lease_id="lease-new-handoff",
+ account_id=new_account.id,
+ kind="stream",
+ acquired_at=2.0,
+ )
+ session.account_lease = old_lease
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-handoff-lease-swap",
+ model="gpt-5.4",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ )
+ replacement = cast(
+ UpstreamWebSocket,
+ SimpleNamespace(response_header=lambda _name: None, close=AsyncMock()),
+ )
+ release_account_lease = AsyncMock()
+
+ async def release_lease(lease: proxy_service.AccountLease | None) -> None:
+ assert lease is old_lease
+ assert session.closed is True
+ assert session.handoff_in_progress is True
+ await release_account_lease(lease)
+
+ async def select_account(_deadline: float, **_: object) -> proxy_service.AccountSelection:
+ return proxy_service.AccountSelection(account=new_account, error_message=None, lease=new_lease)
+
+ async def ensure_fresh(account: object, **_: object) -> object:
+ return account
+
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings())
+ monkeypatch.setattr(
+ proxy_service,
+ "get_settings_cache",
+ lambda: SimpleNamespace(
+ get=AsyncMock(
+ return_value=SimpleNamespace(
+ prefer_earlier_reset_accounts=False,
+ routing_strategy=None,
+ )
+ )
+ ),
+ )
+ monkeypatch.setattr(service, "_select_account_with_budget_for_stream", select_account)
+ monkeypatch.setattr(service, "_ensure_fresh_with_budget", ensure_fresh)
+ monkeypatch.setattr(service, "_open_upstream_websocket_with_budget", AsyncMock(return_value=replacement))
+ monkeypatch.setattr(service._load_balancer, "release_account_lease", release_lease)
+
+ await service._reconnect_http_bridge_session(session, request_state=request_state)
+
+ release_account_lease.assert_awaited_once_with(old_lease)
+ assert session.account is new_account
+ assert session.account_lease is new_lease
+ assert session.closed is False
+ assert session.handoff_in_progress is False
+ assert session.handoff_future is None
+ assert session.key not in service._http_bridge_inflight_sessions
+
+
+@pytest.mark.asyncio
+async def test_reconnect_cancellation_during_wrong_owner_lease_release_completes_handoff(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ session = _make_bridge_session()
+ required_account = cast(Any, SimpleNamespace(id="acc-required", status=AccountStatus.ACTIVE, plan_type="plus"))
+ replacement_account = cast(
+ Any,
+ SimpleNamespace(id="acc-replacement", status=AccountStatus.ACTIVE, plan_type="plus"),
+ )
+ replacement_lease = proxy_service.AccountLease(
+ lease_id="lease-wrong-owner-cancelled",
+ account_id=replacement_account.id,
+ kind="stream",
+ acquired_at=2.0,
+ )
+ release_started = asyncio.Event()
+
+ async def select_account(_deadline: float, **_: object) -> proxy_service.AccountSelection:
+ return proxy_service.AccountSelection(
+ account=replacement_account,
+ error_message=None,
+ error_code=None,
+ lease=replacement_lease,
+ )
+
+ async def release_lease(_lease: proxy_service.AccountLease | None) -> None:
+ release_started.set()
+ await asyncio.Event().wait()
+
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-wrong-owner-cancelled",
+ model="gpt-5.4",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ preferred_account_id=required_account.id,
+ )
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings())
+ monkeypatch.setattr(
+ proxy_service,
+ "get_settings_cache",
+ lambda: SimpleNamespace(
+ get=AsyncMock(
+ return_value=SimpleNamespace(
+ prefer_earlier_reset_accounts=False,
+ routing_strategy=None,
+ )
+ )
+ ),
+ )
+ monkeypatch.setattr(service, "_select_account_with_budget_for_stream", select_account)
+ monkeypatch.setattr(service._load_balancer, "release_account_lease", release_lease)
+
+ reconnect_task = asyncio.create_task(
+ service._reconnect_http_bridge_session(
+ session,
+ request_state=request_state,
+ require_preferred_account=True,
+ )
+ )
+ await asyncio.wait_for(release_started.wait(), timeout=1.0)
+ reconnect_task.cancel()
+
+ with pytest.raises(asyncio.CancelledError):
+ await reconnect_task
+
+ assert session.closed is True
+ assert session.handoff_in_progress is False
+ assert session.handoff_future is None
+ assert session.key not in service._http_bridge_inflight_sessions
+
+
@pytest.mark.asyncio
async def test_reconnect_http_bridge_session_preserves_hard_account_after_1011(
monkeypatch: pytest.MonkeyPatch,
@@ -7388,6 +8142,7 @@ async def test_stream_via_http_bridge_preserves_only_safe_trimmable_full_resend_
prepared_previous_response_ids: list[str | None] = []
prepared_input_lengths: list[int] = []
prepared_frames: list[dict[str, Any]] = []
+ prepare_call_count = 0
real_prepare = service._prepare_http_bridge_request
def fake_prepare(
@@ -7400,9 +8155,17 @@ def fake_prepare(
client_ip: str | None = None,
**kwargs: Any,
) -> tuple[proxy_service._WebSocketRequestState, str]:
- prepared_previous_response_ids.append(prepared_payload.previous_response_id)
+ # The recovery journal fingerprint is prepared from the same payload
+ # before the request is sent. It is internal bookkeeping, not a
+ # second upstream dispatch, so keep it out of dispatch assertions.
+ nonlocal prepare_call_count
+ prepare_call_count += 1
+ record_dispatch = not (preserves_full_resend and prepare_call_count == 1)
+ if record_dispatch:
+ prepared_previous_response_ids.append(prepared_payload.previous_response_id)
inp = prepared_payload.input
- prepared_input_lengths.append(len(inp) if isinstance(inp, list) else 1)
+ if record_dispatch:
+ prepared_input_lengths.append(len(inp) if isinstance(inp, list) else 1)
_, text_data = real_prepare(
prepared_payload,
_headers,
@@ -7412,7 +8175,8 @@ def fake_prepare(
client_ip=client_ip,
**kwargs,
)
- prepared_frames.append(json.loads(text_data))
+ if record_dispatch:
+ prepared_frames.append(json.loads(text_data))
request_state.previous_response_id = prepared_payload.previous_response_id
return request_state, text_data
@@ -7551,7 +8315,10 @@ def fake_prepare(
if not preserves_full_resend:
assert request_state.proxy_injected_previous_response_id is True
assert request_state.fresh_upstream_request_is_retry_safe is False
- account_neutral_classifier.assert_not_called()
+ if preserves_full_resend:
+ account_neutral_classifier.assert_called_once()
+ else:
+ account_neutral_classifier.assert_not_called()
@pytest.mark.asyncio
@@ -8110,6 +8877,88 @@ async def close_http_bridge_session(target: proxy_service._HTTPBridgeSession) ->
assert close_cancelled is False
+@pytest.mark.asyncio
+async def test_await_cancelled_task_consumes_child_cancellation() -> None:
+ child = asyncio.create_task(asyncio.sleep(60))
+
+ assert await proxy_service._await_cancelled_task(child, timeout_seconds=1.0, label="test child") is True
+ assert child.done()
+ assert child.cancelled()
+
+
+@pytest.mark.asyncio
+async def test_await_cancelled_task_propagates_caller_cancellation() -> None:
+ child_cancelled = asyncio.Event()
+ release_child = asyncio.Event()
+
+ async def stubborn_child() -> None:
+ try:
+ await asyncio.Event().wait()
+ except asyncio.CancelledError:
+ child_cancelled.set()
+ await release_child.wait()
+
+ child = asyncio.create_task(stubborn_child())
+ cleanup_tasks: set[asyncio.Task[None]] = set()
+ waiter = asyncio.create_task(
+ proxy_service._await_cancelled_task(
+ child,
+ timeout_seconds=10.0,
+ label="stubborn test child",
+ cleanup_tasks=cleanup_tasks,
+ )
+ )
+ await asyncio.wait_for(child_cancelled.wait(), timeout=1.0)
+
+ waiter.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await waiter
+ assert cleanup_tasks
+
+ release_child.set()
+ await asyncio.wait_for(child, timeout=1.0)
+ for _ in range(10):
+ if not cleanup_tasks:
+ break
+ await asyncio.sleep(0)
+ assert cleanup_tasks == set()
+
+
+@pytest.mark.asyncio
+async def test_await_cancelled_task_defers_stubborn_child_cleanup() -> None:
+ child_cancelled = asyncio.Event()
+ release_child = asyncio.Event()
+
+ async def stubborn_child() -> None:
+ try:
+ await asyncio.Event().wait()
+ except asyncio.CancelledError:
+ child_cancelled.set()
+ await release_child.wait()
+
+ child = asyncio.create_task(stubborn_child())
+ cleanup_tasks: set[asyncio.Task[None]] = set()
+ assert (
+ await proxy_service._await_cancelled_task(
+ child,
+ timeout_seconds=0.001,
+ label="stubborn cleanup",
+ cleanup_tasks=cleanup_tasks,
+ )
+ is False
+ )
+ assert cleanup_tasks
+ await asyncio.wait_for(child_cancelled.wait(), timeout=1.0)
+
+ release_child.set()
+ await asyncio.wait_for(child, timeout=1.0)
+ for _ in range(10):
+ if not cleanup_tasks:
+ break
+ await asyncio.sleep(0)
+ assert cleanup_tasks == set()
+
+
@pytest.mark.asyncio
async def test_close_http_bridge_session_bounded_cancellation_keeps_close_task_tracked(
monkeypatch: pytest.MonkeyPatch,
@@ -8770,6 +9619,7 @@ async def test_stream_via_http_bridge_preserves_context_after_owner_unavailable(
request_states: list[proxy_service._WebSocketRequestState] = []
prepared_previous_response_ids: list[str | None] = []
prepared_inputs: list[proxy_service.JsonValue] = []
+ prepare_call_count = 0
def fake_prepare(
prepared_payload: proxy_service.ResponsesRequest,
@@ -8781,10 +9631,14 @@ def fake_prepare(
client_ip: str | None = None,
) -> tuple[proxy_service._WebSocketRequestState, str]:
del api_key, api_key_reservation, request_id, client_ip
- prepared_previous_response_ids.append(prepared_payload.previous_response_id)
- prepared_inputs.append(prepared_payload.input)
+ nonlocal prepare_call_count
+ prepare_call_count += 1
+ record_dispatch = not (retains_prior_output and prepare_call_count == 1)
+ if record_dispatch:
+ prepared_previous_response_ids.append(prepared_payload.previous_response_id)
+ prepared_inputs.append(prepared_payload.input)
state = proxy_service._WebSocketRequestState(
- request_id=f"req-{len(request_states)}",
+ request_id=f"req-{prepare_call_count}",
model="gpt-5.4",
service_tier=None,
reasoning_effort=None,
@@ -8794,7 +9648,8 @@ def fake_prepare(
previous_response_id=prepared_payload.previous_response_id,
transport="http",
)
- request_states.append(state)
+ if record_dispatch:
+ request_states.append(state)
return state, proxy_service._response_create_text(
prepared_payload,
include_type_field=True,
@@ -10653,6 +11508,7 @@ async def produce_after_reattach_delay() -> None:
monkeypatch.setattr(http_bridge_streaming_module, "_http_bridge_account_capacity_wait_seconds", lambda _exc: 0.001)
monkeypatch.setattr(http_bridge_streaming_module, "_ACCOUNT_SELECTION_RECOVERY_HEARTBEAT_SECONDS", 0.001)
monkeypatch.setattr(http_bridge_streaming_module, "_http_bridge_startup_keepalive_grace_seconds", lambda: 0.001)
+ monkeypatch.setattr(proxy_service, "_STREAM_KEEPALIVE_MAX_COUNT", 20)
chunks = [
chunk
@@ -10675,7 +11531,7 @@ async def produce_after_reattach_delay() -> None:
keepalive = proxy_service.parse_sse_data_json(chunks[0])
assert keepalive is not None
assert keepalive["status"] == "waiting_for_account_capacity"
- assert http_bridge_streaming_module._codex_keepalive_frame() in chunks
+ assert any('"type":"codex.keepalive"' in chunk for chunk in chunks)
assert chunks[-1] == 'data: {"type":"response.completed"}\n\n'
assert get_or_create.await_count == 3
assert prepare_reservations == [initial_reservation, retried_reservation]
@@ -11369,7 +12225,7 @@ async def fake_stream_http_bridge_session_events(
assert exc_info.value.status_code == 400
assert exc_info.value.payload["error"]["code"] == "context_length_exceeded"
assert key not in service._http_bridge_sessions
- close_session.assert_awaited_once_with(session)
+ close_session.assert_awaited_once_with(session, release_durable_session=True)
assert isinstance(failed_block, str)
assert '"type":"response.failed"' in failed_block
assert '"code":"stream_incomplete"' in failed_block
@@ -14462,10 +15318,10 @@ async def ambiguous_send(_text: str) -> None:
queue_limit=2,
)
)
- for _ in range(20):
+ for _ in range(100):
if session.admission_waiter_count == 1:
break
- await asyncio.sleep(0)
+ await asyncio.sleep(0.001)
assert session.admission_waiter_count == 1
prewarm_task.cancel()
@@ -15696,21 +16552,73 @@ async def test_recovery_submit_alias_persistence_failure_retires_before_send() -
@pytest.mark.asyncio
-async def test_recovery_submit_cancellation_after_alias_commit_restores_previous_owner() -> None:
+async def test_recovery_submit_owner_fence_rejection_retires_before_send() -> None:
service = proxy_service.ProxyService(cast(Any, nullcontext()))
- key = _make_account_neutral_replay_session_key("alias-commit-cancel")
send_text = AsyncMock()
close = AsyncMock()
- session = _make_bridge_session(key=key)
+ session = _make_bridge_session(key_value="owner-fence-rejection")
session.upstream = cast(
UpstreamWebSocket,
SimpleNamespace(send_text=send_text, close=close),
)
- session.durable_session_id = "durable-recovery"
- session.durable_owner_epoch = 4
- service._http_bridge_sessions[key] = session
- alias_owner = {"http_turn_commit_cancel": "durable-predecessor"}
- alias_committed = asyncio.Event()
+ session.durable_session_id = "durable-owner-fence-rejection"
+ session.durable_owner_epoch = 2
+ service._http_bridge_sessions[session.key] = session
+ record_recovery_attempt = AsyncMock(return_value=None)
+ service._durable_bridge = cast(
+ Any,
+ SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(return_value=None),
+ record_recovery_attempt=record_recovery_attempt,
+ release_live_session=AsyncMock(return_value=None),
+ ),
+ )
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-owner-fence-rejection",
+ model="gpt-5.6-sol",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ awaiting_response_created=True,
+ request_text='{"type":"response.create","model":"gpt-5.6-sol","input":"hi"}',
+ fresh_upstream_request_text='{"type":"response.create","model":"gpt-5.6-sol","input":"hi"}',
+ fresh_upstream_request_is_retry_safe=True,
+ transport="http",
+ skip_request_log=True,
+ )
+
+ with pytest.raises(proxy_service.ProxyResponseError) as exc_info:
+ await service._submit_http_bridge_request(
+ session,
+ request_state=request_state,
+ text_data=request_state.request_text or "{}",
+ queue_limit=8,
+ )
+
+ assert exc_info.value.status_code == 502
+ assert exc_info.value.payload["error"]["code"] == "bridge_continuity_persistence_failed"
+ record_recovery_attempt.assert_awaited_once()
+ send_text.assert_not_awaited()
+ assert session.closed is True
+
+
+@pytest.mark.asyncio
+async def test_recovery_submit_cancellation_after_alias_commit_restores_previous_owner() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ key = _make_account_neutral_replay_session_key("alias-commit-cancel")
+ send_text = AsyncMock()
+ close = AsyncMock()
+ session = _make_bridge_session(key=key)
+ session.upstream = cast(
+ UpstreamWebSocket,
+ SimpleNamespace(send_text=send_text, close=close),
+ )
+ session.durable_session_id = "durable-recovery"
+ session.durable_owner_epoch = 4
+ service._http_bridge_sessions[key] = session
+ alias_owner = {"http_turn_commit_cancel": "durable-predecessor"}
+ alias_committed = asyncio.Event()
release_registration = asyncio.Event()
async def register_recovery_turn_state(**_kwargs: Any) -> DurableBridgeAliasRegistrationReceipt:
@@ -17803,8 +18711,10 @@ async def fake_stream_events(
last_call = get_or_create.await_args
assert last_call is not None
assert last_call.kwargs["previous_response_id"] is None
- if unsafe_replay_input in {"missing_owner", "missing_prior_output", "orphan_output"}:
+ if unsafe_replay_input in {"missing_prior_output", "orphan_output"}:
account_neutral_classifier.assert_not_called()
+ elif unsafe_replay_input == "missing_owner":
+ account_neutral_classifier.assert_called_once()
else:
account_neutral_classifier.assert_called_once()
return
@@ -18444,7 +19354,7 @@ async def close(self) -> None:
assert owner.response_event_count == 0
if leading_telemetry:
assert owner.latency_first_upstream_event_ms is not None
- retry_precreated.assert_not_awaited()
+ retry_precreated.assert_awaited_once_with(session)
assert write_request_log.await_count == 2
assert {call.kwargs["error_code"] for call in write_request_log.await_args_list} == {"upstream_request_timeout"}
fail_reader.assert_awaited_once()
@@ -18633,6 +19543,251 @@ async def reconnect_during_failure(*_args: object, **_kwargs: object) -> bool:
assert session.closed is False
+@pytest.mark.asyncio
+async def test_http_bridge_reconnect_failure_keeps_reader_handoff_session_closed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-reconnect-fails-closed",
+ model="gpt-5.4",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ request_text='{"type":"response.create","model":"gpt-5.4","input":"hello"}',
+ transport="http",
+ )
+ session = _make_bridge_session(key_value="bridge-reconnect-fails-closed")
+ session.closed = True
+ session.upstream = cast(UpstreamWebSocket, SimpleNamespace(close=AsyncMock()))
+
+ async def old_reader() -> None:
+ await asyncio.sleep(60.0)
+
+ session.upstream_reader = asyncio.create_task(old_reader())
+ settings = SimpleNamespace(
+ prefer_earlier_reset_accounts=False,
+ prefer_earlier_reset_window="primary",
+ routing_strategy="usage_weighted",
+ )
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings())
+ monkeypatch.setattr(
+ proxy_service,
+ "get_settings_cache",
+ lambda: SimpleNamespace(get=AsyncMock(return_value=settings)),
+ )
+
+ async def select_no_account(*_args: object, **_kwargs: object) -> object:
+ assert session.closed is True
+ return SimpleNamespace(
+ account=None,
+ error_code="no_accounts",
+ error_message="No active accounts available",
+ )
+
+ monkeypatch.setattr(service, "_select_account_with_budget_for_stream", AsyncMock(side_effect=select_no_account))
+
+ with pytest.raises(ProxyResponseError):
+ await service._reconnect_http_bridge_session(
+ session,
+ request_state=request_state,
+ restart_reader=True,
+ )
+
+ assert session.closed is True
+ assert session.handoff_in_progress is False
+ assert session.handoff_future is None
+ assert session.key not in service._http_bridge_inflight_sessions
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_handoff_future_survives_same_key_and_capacity_timeouts(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ key = proxy_service._HTTPBridgeSessionKey("session_header", "sid-timeout-handoff", None)
+ session = _make_bridge_session(key=key, key_value="sid-timeout-handoff", queued_request_count=1)
+ handoff_future: asyncio.Future[proxy_service._HTTPBridgeSession] = asyncio.get_running_loop().create_future()
+ session.closed = True
+ session.handoff_in_progress = True
+ session.handoff_future = handoff_future
+ setattr(handoff_future, "_http_bridge_handoff", True)
+ service._http_bridge_sessions[key] = session
+ service._http_bridge_inflight_sessions[key] = handoff_future
+ settings = _make_app_settings(proxy_admission_wait_timeout_seconds=0.01)
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
+ monkeypatch.setattr(service, "_prune_http_bridge_sessions_locked", Mock(return_value=[]))
+ monkeypatch.setattr(service, "_http_bridge_pending_count", AsyncMock(return_value=1))
+ monkeypatch.setattr(proxy_service, "_http_bridge_should_wait_for_registration", AsyncMock(return_value=False))
+ monkeypatch.setattr(proxy_service, "_http_bridge_owner_instance", AsyncMock(return_value="instance-a"))
+ monkeypatch.setattr(
+ proxy_service,
+ "_active_http_bridge_instance_ring",
+ AsyncMock(return_value=("instance-a", ("instance-a",))),
+ )
+
+ with pytest.raises(ProxyResponseError):
+ await service._get_or_create_http_bridge_session(
+ key,
+ headers={"x-codex-session-id": "sid-timeout-handoff"},
+ affinity=proxy_service._AffinityPolicy(
+ key="sid-timeout-handoff",
+ kind=proxy_service.StickySessionKind.CODEX_SESSION,
+ ),
+ api_key=None,
+ request_model="gpt-5.4",
+ idle_ttl_seconds=120.0,
+ max_sessions=8,
+ )
+ assert service._http_bridge_inflight_sessions[key] is handoff_future
+
+ with pytest.raises(ProxyResponseError):
+ await service._get_or_create_http_bridge_session(
+ proxy_service._HTTPBridgeSessionKey("session_header", "sid-capacity-waiter", None),
+ headers={"x-codex-session-id": "sid-capacity-waiter"},
+ affinity=proxy_service._AffinityPolicy(
+ key="sid-capacity-waiter",
+ kind=proxy_service.StickySessionKind.CODEX_SESSION,
+ ),
+ api_key=None,
+ request_model="gpt-5.4",
+ idle_ttl_seconds=120.0,
+ max_sessions=1,
+ )
+ assert service._http_bridge_inflight_sessions[key] is handoff_future
+
+ proxy_support_module._complete_http_bridge_handoff(session, service._http_bridge_inflight_sessions)
+ assert handoff_future.result() is session
+
+
+@pytest.mark.asyncio
+async def test_retry_http_bridge_precreated_request_consumes_each_clean_close_once(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-clean-close-generation",
+ model="gpt-5.4",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ awaiting_response_created=True,
+ request_text='{"type":"response.create","model":"gpt-5.4","input":"hello"}',
+ transport="http",
+ replay_count=1,
+ account_response_create_lease=cast(Any, object()),
+ )
+ session = _make_bridge_session(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-clean-generation", None),
+ key_value="bridge-clean-generation",
+ pending_requests=deque([request_state]),
+ queued_request_count=1,
+ )
+ session.last_upstream_close_code = 1000
+ session.last_upstream_close_generation = 7
+ send_text = AsyncMock()
+ session.upstream = cast(UpstreamWebSocket, SimpleNamespace(send_text=send_text, close=AsyncMock()))
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings())
+ monkeypatch.setattr(service, "_reconnect_http_bridge_session", AsyncMock())
+ monkeypatch.setattr(service, "_release_request_state_account_response_create_lease", AsyncMock())
+
+ assert await service._retry_http_bridge_precreated_request(session) is True
+ assert await service._retry_http_bridge_precreated_request(session) is False
+ assert request_state.clean_close_replay_count == 1
+ assert request_state.clean_close_retry_close_generation == 7
+ assert send_text.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_retry_http_bridge_model_fallback_excludes_rejected_hard_affinity_account(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-model-fallback-account",
+ model="gpt-5.4",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ awaiting_response_created=True,
+ request_text='{"type":"response.create","model":"gpt-5.4","input":"hello"}',
+ transport="http",
+ account_response_create_lease=cast(Any, object()),
+ precreated_replay_reason="account_model_unsupported",
+ precreated_replay_account_id="acc-rejected",
+ )
+ session = _make_bridge_session(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-model-fallback", None),
+ key_value="bridge-model-fallback",
+ pending_requests=deque([request_state]),
+ queued_request_count=1,
+ )
+ session.account = cast(Any, SimpleNamespace(id="acc-rejected", status=AccountStatus.ACTIVE))
+ session.last_upstream_close_code = 1011
+ session.upstream = cast(UpstreamWebSocket, SimpleNamespace(send_text=AsyncMock(), close=AsyncMock()))
+ old_lease = request_state.account_response_create_lease
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings())
+ reconnect = AsyncMock()
+ monkeypatch.setattr(service, "_reconnect_http_bridge_session", reconnect)
+ release_lease = AsyncMock()
+ monkeypatch.setattr(service._load_balancer, "release_account_lease", release_lease)
+
+ assert await service._retry_http_bridge_precreated_request(session) is True
+ assert request_state.preferred_account_id is None
+ assert request_state.excluded_account_ids == {"acc-rejected"}
+ reconnect.assert_awaited_once()
+ reconnect_call = reconnect.await_args
+ assert reconnect_call is not None
+ assert reconnect_call.kwargs["request_state"] is request_state
+ release_lease.assert_awaited_once_with(old_lease)
+
+
+@pytest.mark.asyncio
+async def test_retry_http_bridge_fresh_hard_request_excludes_silent_account(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-fresh-hard-account-fallback",
+ model="gpt-5.6-luna",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ awaiting_response_created=True,
+ request_text='{"type":"response.create","model":"gpt-5.6-luna","input":"hello"}',
+ transport="http",
+ account_response_create_lease=cast(Any, object()),
+ )
+ session = _make_bridge_session(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-fresh-hard", None),
+ key_value="bridge-fresh-hard",
+ pending_requests=deque([request_state]),
+ queued_request_count=1,
+ )
+ session.account = cast(Any, SimpleNamespace(id="acc-silent", status=AccountStatus.ACTIVE))
+ session.last_upstream_close_code = 1011
+ session.upstream = cast(Any, SimpleNamespace(send_text=AsyncMock(), close=AsyncMock()))
+ old_lease = request_state.account_response_create_lease
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings())
+ reconnect = AsyncMock()
+ monkeypatch.setattr(service, "_reconnect_http_bridge_session", reconnect)
+ release_lease = AsyncMock()
+ monkeypatch.setattr(service._load_balancer, "release_account_lease", release_lease)
+
+ assert await service._retry_http_bridge_precreated_request(session) is True
+ assert request_state.preferred_account_id is None
+ assert request_state.excluded_account_ids == {"acc-silent"}
+ reconnect.assert_awaited_once()
+ reconnect_call = reconnect.await_args
+ assert reconnect_call is not None
+ assert reconnect_call.kwargs["request_state"] is request_state
+ release_lease.assert_awaited_once_with(old_lease)
+
+
@pytest.mark.asyncio
async def test_http_bridge_retry_send_network_failure_is_neutral_and_not_replayed(
monkeypatch: pytest.MonkeyPatch,
@@ -18726,6 +19881,32 @@ async def test_http_bridge_reader_preserves_routed_aiohttp_close_code(
routed_websocket.close.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_http_bridge_clean_close_before_response_does_not_penalize_account(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ session = _make_bridge_session(key_value="bridge-clean-close")
+ session.upstream = cast(
+ UpstreamWebSocket,
+ SimpleNamespace(
+ receive=AsyncMock(return_value=UpstreamWebSocketMessage(kind="close", close_code=1000)),
+ close=AsyncMock(),
+ ),
+ )
+ fail_pending = AsyncMock()
+ retire = AsyncMock()
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings())
+ monkeypatch.setattr(service, "_fail_pending_websocket_requests", fail_pending)
+ monkeypatch.setattr(service, "_retire_stale_pending_http_bridge_session", retire)
+
+ await service._relay_http_bridge_upstream_messages(session)
+
+ assert fail_pending.await_args is not None
+ assert fail_pending.await_args.kwargs["penalize_account"] is False
+ retire.assert_awaited_once_with(session, detail="stream_incomplete", response_events_seen=0)
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize("routed", [False, True], ids=["direct-close", "routed-receive-error"])
async def test_http_bridge_reader_maps_ordinary_websocket_receive_failure_to_stream_incomplete(
@@ -18775,6 +19956,13 @@ async def fail_reader(
assert len(failure_calls) == 1
assert failure_calls[0]["error_code"] == "stream_incomplete"
assert failure_calls[0]["penalize_account"] is True
+ assert failure_calls[0]["response_events_seen"] == 0
+ if routed:
+ assert failure_calls[0]["upstream_close_code"] is None
+ assert failure_calls[0]["transport_classification"] == "websocket_transport_error"
+ else:
+ assert failure_calls[0]["upstream_close_code"] == 1011
+ assert failure_calls[0]["transport_classification"] == "websocket_close_transient"
@pytest.mark.asyncio
@@ -18888,6 +20076,591 @@ async def test_retire_stale_pending_http_bridge_session_unregisters_aliases_and_
close.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_http_bridge_retirement_does_not_record_midstream_retry_circuit_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ session = _make_bridge_session(key_value="bridge-midstream-retire")
+ record_failure = AsyncMock()
+ close = AsyncMock()
+ monkeypatch.setattr(service, "_record_http_bridge_retry_circuit_failure", record_failure)
+ monkeypatch.setattr(service, "_close_http_bridge_session_bounded", close)
+
+ await service._retire_stale_pending_http_bridge_session(
+ session,
+ detail="stream_incomplete",
+ response_events_seen=1,
+ )
+
+ record_failure.assert_not_awaited()
+ close.assert_awaited_once_with(session, reason="retire_stale_pending")
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_backoff_is_scoped_to_repeated_hard_keys() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-circuit-hard")
+ other_session = _make_bridge_session(key_value="bridge-circuit-other")
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is True
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_incomplete")
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is True
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_incomplete")
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert await service._http_bridge_precreated_retry_allowed(other_session) is True
+
+ await service._clear_http_bridge_retry_circuit(hard_session)
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is True
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_allows_only_one_half_open_probe() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-circuit-half-open")
+ now = time.monotonic()
+ cast(Any, service)._http_bridge_retry_circuits[hard_session.key] = (
+ http_bridge_retry_circuit_module._HTTPBridgeRetryCircuitState(
+ consecutive_failures=2,
+ cooldown_until=now - 1.0,
+ last_detail="missing_response_created_timeout",
+ last_touched_monotonic=now,
+ )
+ )
+ service._durable_bridge = SimpleNamespace(lookup_retry_circuit=AsyncMock(return_value=None))
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is True
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert (
+ await service._http_bridge_precreated_retry_allowed(
+ hard_session,
+ allow_proof_gated_continuity_replay=True,
+ )
+ is True
+ )
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_allows_fresh_hard_account_switch_during_cooldown() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-fresh-circuit-bypass",
+ model="gpt-5.6-luna",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ transport="http",
+ awaiting_response_created=True,
+ request_text='{"type":"response.create","model":"gpt-5.6-luna","input":"hello"}',
+ )
+ hard_session = _make_bridge_session(
+ key_value="bridge-circuit-fresh-bypass",
+ pending_requests=deque([request_state]),
+ queued_request_count=1,
+ )
+
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_idle_timeout")
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_idle_timeout")
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert (
+ await service._http_bridge_precreated_retry_allowed(
+ hard_session,
+ allow_fresh_hard_account_switch=True,
+ )
+ is True
+ )
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_allows_proof_gated_continuity_replay_during_cooldown() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-proof-gated-circuit-bypass",
+ model="gpt-5.6-luna",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ transport="http",
+ awaiting_response_created=True,
+ request_text='{"type":"response.create","previous_response_id":"resp_anchor"}',
+ previous_response_id="resp_anchor",
+ fresh_upstream_request_text='{"type":"response.create","input":"full resend"}',
+ fresh_upstream_request_is_retry_safe=True,
+ )
+ hard_session = _make_bridge_session(
+ key_value="bridge-circuit-proof-gated",
+ pending_requests=deque([request_state]),
+ queued_request_count=1,
+ )
+
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_idle_timeout")
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_idle_timeout")
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert (
+ await service._http_bridge_precreated_retry_allowed(
+ hard_session,
+ allow_proof_gated_continuity_replay=True,
+ )
+ is True
+ )
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_clean_close_retry_circuit_is_bounded() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ session = _make_bridge_session(key_value="bridge-clean-close-circuit")
+
+ await service._record_http_bridge_retry_circuit_failure(session, detail="clean_close")
+ await service._record_http_bridge_retry_circuit_failure(session, detail="clean_close")
+
+ cooldown = await service._http_bridge_precreated_retry_cooldown_seconds(session)
+ assert 0 < cooldown <= 30.0
+ retry_circuits = cast(Any, service)._http_bridge_retry_circuits
+ assert retry_circuits[session.key].last_detail == "clean_close"
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_restores_persisted_cooldown() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-persisted-circuit")
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(
+ return_value=SimpleNamespace(
+ consecutive_failures=2,
+ cooldown_until_epoch=time.time() + 60.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=time.time(),
+ )
+ ),
+ clear_retry_circuit=AsyncMock(),
+ persist_retry_circuit=AsyncMock(),
+ )
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert await service._http_bridge_precreated_retry_cooldown_seconds(hard_session) > 0
+ assert service._durable_bridge.lookup_retry_circuit.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_purges_expired_persisted_state() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-expired-circuit")
+ expired_updated_at = (
+ time.time() - http_bridge_retry_circuit_module.DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS - 1.0
+ )
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(
+ return_value=SimpleNamespace(
+ consecutive_failures=2,
+ cooldown_until_epoch=time.time() + 60.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=expired_updated_at,
+ )
+ ),
+ purge_retry_circuit=AsyncMock(),
+ )
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is True
+ service._durable_bridge.purge_retry_circuit.assert_awaited_once_with(
+ session_key_kind=hard_session.key.affinity_kind,
+ session_key_value=hard_session.key.affinity_key,
+ api_key_id=hard_session.key.api_key_id,
+ expected_updated_at_epoch=expired_updated_at,
+ )
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_keeps_newer_local_failure_after_stale_purge() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-expired-circuit-new-local-failure")
+ now = time.monotonic()
+ local_state = http_bridge_retry_circuit_module._HTTPBridgeRetryCircuitState(
+ consecutive_failures=2,
+ cooldown_until=now + 60.0,
+ last_detail="stream_idle_timeout",
+ last_touched_monotonic=now,
+ last_failure_monotonic=now,
+ last_durable_load_monotonic=now - 10.0,
+ )
+ cast(Any, service)._http_bridge_retry_circuits[hard_session.key] = local_state
+ cast(Any, service)._http_bridge_retry_circuit_persisted_keys.add(hard_session.key)
+ expired_updated_at = (
+ time.time() - http_bridge_retry_circuit_module.DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS - 1.0
+ )
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(
+ return_value=SimpleNamespace(
+ consecutive_failures=2,
+ cooldown_until_epoch=time.time() + 60.0,
+ last_detail="stream_idle_timeout",
+ updated_at_epoch=expired_updated_at,
+ )
+ ),
+ purge_retry_circuit=AsyncMock(),
+ )
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert cast(Any, service)._http_bridge_retry_circuits[hard_session.key] is local_state
+ service._durable_bridge.purge_retry_circuit.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_keeps_local_state_when_stale_purge_fails() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-expired-circuit-local-fallback")
+ now = time.monotonic()
+ local_state = http_bridge_retry_circuit_module._HTTPBridgeRetryCircuitState(
+ consecutive_failures=2,
+ cooldown_until=now + 60.0,
+ last_detail="stream_idle_timeout",
+ last_touched_monotonic=now,
+ last_failure_monotonic=now,
+ )
+ cast(Any, service)._http_bridge_retry_circuits[hard_session.key] = local_state
+ cast(Any, service)._http_bridge_retry_circuit_persisted_keys.add(hard_session.key)
+ expired_updated_at = (
+ time.time() - http_bridge_retry_circuit_module.DURABLE_BRIDGE_RETRY_CIRCUIT_STATE_TTL_SECONDS - 1.0
+ )
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(
+ return_value=SimpleNamespace(
+ consecutive_failures=2,
+ cooldown_until_epoch=time.time() + 60.0,
+ last_detail="stream_idle_timeout",
+ updated_at_epoch=expired_updated_at,
+ )
+ ),
+ purge_retry_circuit=AsyncMock(side_effect=RuntimeError("durable purge unavailable")),
+ )
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert cast(Any, service)._http_bridge_retry_circuits[hard_session.key] is local_state
+ assert local_state.consecutive_failures == 2
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_refreshes_persisted_state_after_initial_miss() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-replica-refresh-circuit")
+ persisted = SimpleNamespace(
+ consecutive_failures=2,
+ cooldown_until_epoch=time.time() + 60.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=time.time(),
+ )
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(side_effect=[None, persisted]),
+ clear_retry_circuit=AsyncMock(),
+ persist_retry_circuit=AsyncMock(),
+ )
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is True
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert service._durable_bridge.lookup_retry_circuit.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_preserves_newer_local_failure_on_durable_refresh() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-local-newer-than-durable-refresh")
+ now_monotonic = time.monotonic()
+ local_state = http_bridge_retry_circuit_module._HTTPBridgeRetryCircuitState(
+ consecutive_failures=2,
+ cooldown_until=now_monotonic + 90.0,
+ last_detail="stream_idle_timeout",
+ last_touched_monotonic=now_monotonic,
+ last_failure_monotonic=now_monotonic,
+ last_durable_load_monotonic=now_monotonic - 10.0,
+ persisted_updated_at_epoch=time.time() - 10.0,
+ )
+ cast(Any, service)._http_bridge_retry_circuits[hard_session.key] = local_state
+ cast(Any, service)._http_bridge_retry_circuit_persisted_keys.add(hard_session.key)
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(
+ return_value=SimpleNamespace(
+ consecutive_failures=1,
+ cooldown_until_epoch=time.time() + 1.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=time.time(),
+ )
+ )
+ )
+
+ await service._load_http_bridge_retry_circuit(hard_session)
+
+ assert cast(Any, service)._http_bridge_retry_circuits[hard_session.key] is local_state
+ assert local_state.consecutive_failures == 2
+ assert local_state.cooldown_until >= now_monotonic + 89.0
+ assert local_state.last_detail == "stream_idle_timeout"
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_drops_local_state_after_durable_clear() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-replica-cleared-circuit")
+ persisted = SimpleNamespace(
+ consecutive_failures=2,
+ cooldown_until_epoch=time.time() + 60.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=time.time(),
+ )
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(side_effect=[persisted, None]),
+ clear_retry_circuit=AsyncMock(),
+ persist_retry_circuit=AsyncMock(),
+ )
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is True
+ assert hard_session.key not in cast(Any, service)._http_bridge_retry_circuits
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_clear_retries_after_lookup_failure() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-retry-clear-after-lookup-failure")
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(side_effect=RuntimeError("durable read unavailable")),
+ clear_retry_circuit=AsyncMock(),
+ )
+ state = http_bridge_retry_circuit_module._HTTPBridgeRetryCircuitState(
+ consecutive_failures=2,
+ cooldown_until=time.monotonic() + 60.0,
+ last_detail="stream_incomplete",
+ last_touched_monotonic=time.monotonic(),
+ )
+ cast(Any, service)._http_bridge_retry_circuits[hard_session.key] = state
+ cast(Any, service)._http_bridge_retry_circuit_persisted_keys.add(hard_session.key)
+
+ await service._clear_http_bridge_retry_circuit(hard_session)
+
+ service._durable_bridge.clear_retry_circuit.assert_awaited_once_with(
+ session_key_kind=hard_session.key.affinity_kind,
+ session_key_value=hard_session.key.affinity_key,
+ api_key_id=hard_session.key.api_key_id,
+ expected_updated_at_epoch=None,
+ )
+ assert hard_session.key not in cast(Any, service)._http_bridge_retry_circuits
+ assert hard_session.key not in cast(Any, service)._http_bridge_retry_circuit_persisted_keys
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_replaces_local_state_after_newer_reset() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-replica-reset-lineage")
+ now_epoch = time.time()
+ reset = SimpleNamespace(
+ consecutive_failures=0,
+ cooldown_until_epoch=0.0,
+ last_detail=None,
+ updated_at_epoch=now_epoch,
+ )
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(
+ side_effect=[
+ SimpleNamespace(
+ consecutive_failures=3,
+ cooldown_until_epoch=now_epoch + 60.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=now_epoch - 1.0,
+ ),
+ reset,
+ ]
+ ),
+ persist_retry_circuit=AsyncMock(
+ return_value=SimpleNamespace(
+ consecutive_failures=1,
+ cooldown_until_epoch=0.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=now_epoch + 1.0,
+ )
+ ),
+ )
+
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_incomplete")
+
+ state = cast(Any, service)._http_bridge_retry_circuits[hard_session.key]
+ assert state.consecutive_failures == 1
+ assert state.persisted_updated_at_epoch == now_epoch + 1.0
+ assert service._durable_bridge.persist_retry_circuit.await_args.kwargs["base_updated_at_epoch"] == now_epoch
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_refreshes_conflict_merged_persisted_state() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-conflict-merged-circuit")
+ loaded = SimpleNamespace(
+ consecutive_failures=1,
+ cooldown_until_epoch=time.time() + 60.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=time.time(),
+ )
+ persisted = SimpleNamespace(
+ consecutive_failures=2,
+ cooldown_until_epoch=time.time() + 60.0,
+ last_detail="stream_incomplete",
+ updated_at_epoch=time.time(),
+ )
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(return_value=loaded),
+ persist_retry_circuit=AsyncMock(return_value=persisted),
+ )
+
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_incomplete")
+
+ retry_circuits = cast(Any, service)._http_bridge_retry_circuits
+ assert retry_circuits[hard_session.key].consecutive_failures == 2
+ assert retry_circuits[hard_session.key].cooldown_until > time.monotonic()
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_counts_stream_idle_timeout() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-idle-timeout-circuit")
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(return_value=None),
+ persist_retry_circuit=AsyncMock(),
+ )
+
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="stream_idle_timeout")
+
+ assert cast(Any, service)._http_bridge_retry_circuits[hard_session.key].consecutive_failures == 1
+
+
+@pytest.mark.parametrize(
+ ("error_code", "expected_ambiguous"),
+ [
+ ("stream_incomplete", True),
+ ("stream_idle_timeout", True),
+ ("upstream_request_timeout", True),
+ ("invalid_request_error", False),
+ ("quota_exceeded", False),
+ ],
+)
+def test_http_bridge_durable_recovery_requires_ambiguous_transport_error(
+ error_code: str,
+ expected_ambiguous: bool,
+) -> None:
+ error = ProxyResponseError(502, openai_error(error_code, "upstream result"))
+
+ assert http_bridge_streaming_module._http_bridge_error_is_ambiguous_transport(error) is expected_ambiguous
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_counts_missing_response_created_timeout() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-missing-response-created-circuit")
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(return_value=None),
+ persist_retry_circuit=AsyncMock(),
+ )
+
+ await service._record_http_bridge_retry_circuit_failure(
+ hard_session,
+ detail="missing_response_created_timeout",
+ )
+ await service._record_http_bridge_retry_circuit_failure(
+ hard_session,
+ detail="missing_response_created_timeout",
+ )
+
+ state = cast(Any, service)._http_bridge_retry_circuits[hard_session.key]
+ assert state.consecutive_failures == 2
+ assert state.last_detail == "stream_idle_timeout"
+ assert state.cooldown_until > time.monotonic()
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is False
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_counts_stuck_gate_timeout() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-stuck-gate-circuit")
+ service._durable_bridge = SimpleNamespace(
+ lookup_retry_circuit=AsyncMock(return_value=None),
+ persist_retry_circuit=AsyncMock(),
+ )
+
+ for _ in range(2):
+ await service._record_http_bridge_retry_circuit_failure(
+ hard_session,
+ detail="response_create_gate_timeout_stuck_pending",
+ )
+
+ state = cast(Any, service)._http_bridge_retry_circuits[hard_session.key]
+ assert state.consecutive_failures == 2
+ assert state.last_detail == "stream_idle_timeout"
+ assert state.cooldown_until > time.monotonic()
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_submit_suppresses_hard_key_during_retry_cooldown() -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ hard_session = _make_bridge_session(key_value="bridge-submit-cooldown")
+ now = time.monotonic()
+ cast(Any, service)._http_bridge_retry_circuits[hard_session.key] = (
+ http_bridge_retry_circuit_module._HTTPBridgeRetryCircuitState(
+ consecutive_failures=2,
+ cooldown_until=now + 60.0,
+ last_detail="stream_idle_timeout",
+ last_touched_monotonic=now,
+ )
+ )
+ service._durable_bridge = SimpleNamespace(lookup_retry_circuit=AsyncMock(return_value=None))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req-submit-cooldown",
+ model="gpt-5.6-luna",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ transport="http",
+ awaiting_response_created=True,
+ request_text='{"type":"response.create","input":"hello"}',
+ )
+
+ with pytest.raises(ProxyResponseError) as exc_info:
+ await service._submit_http_bridge_request_with_handoff(
+ hard_session,
+ request_state=request_state,
+ text_data=request_state.request_text or "",
+ queue_limit=8,
+ request_scope_id="scope-submit-cooldown",
+ )
+
+ assert exc_info.value.status_code == 503
+ assert exc_info.value.payload["error"]["code"] == "upstream_request_timeout"
+ assert exc_info.value.retry_after_seconds is not None
+ assert exc_info.value.retry_after_seconds >= 60
+ assert hard_session.queued_request_count == 0
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_retry_circuit_ignores_soft_affinity_and_other_failures(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ service = proxy_service.ProxyService(cast(Any, nullcontext()))
+ monkeypatch.setattr(
+ http_bridge_retry_circuit_module,
+ "_HTTP_BRIDGE_RETRY_CIRCUIT_FAILURE_THRESHOLD",
+ 1,
+ )
+ soft_key = proxy_service._HTTPBridgeSessionKey("prompt_cache", "bridge-circuit-soft", None)
+ soft_session = _make_bridge_session(key=soft_key)
+
+ await service._record_http_bridge_retry_circuit_failure(soft_session, detail="stream_incomplete")
+ assert await service._http_bridge_precreated_retry_allowed(soft_session) is True
+
+ hard_session = _make_bridge_session(key_value="bridge-circuit-other-error")
+ await service._record_http_bridge_retry_circuit_failure(hard_session, detail="proxy_network_unavailable")
+ assert await service._http_bridge_precreated_retry_allowed(hard_session) is True
+
+
@pytest.mark.asyncio
async def test_http_bridge_reader_failed_precreated_replay_retires_registered_session(
monkeypatch: pytest.MonkeyPatch,
@@ -19106,7 +20879,9 @@ async def test_http_bridge_reader_failure_keeps_waiter_count_when_draining_reque
)
session.admission_waiter_count = 1
fail_pending = AsyncMock()
+ record_failure = AsyncMock()
monkeypatch.setattr(service, "_fail_pending_websocket_requests", fail_pending)
+ monkeypatch.setattr(service, "_record_http_bridge_retry_circuit_failure", record_failure)
retired = await service._fail_http_bridge_reader_and_maybe_retire(
session,
@@ -19116,6 +20891,7 @@ async def test_http_bridge_reader_failure_keeps_waiter_count_when_draining_reque
assert retired is False
assert session.queued_request_count == 1
+ record_failure.assert_awaited_once_with(session, detail="stream_incomplete")
@pytest.mark.asyncio
@@ -19141,7 +20917,7 @@ async def test_http_bridge_eventless_timeout_force_retires_with_admission_waiter
assert retired is True
assert session.closed is True
- retire.assert_awaited_once_with(session, detail="missing_response_created_timeout")
+ retire.assert_awaited_once_with(session, detail="missing_response_created_timeout", response_events_seen=0)
fail_pending_await_args = fail_pending.await_args
assert fail_pending_await_args is not None
assert fail_pending_await_args.kwargs["penalize_account"] is False
@@ -19165,7 +20941,7 @@ async def test_http_bridge_reader_failure_retires_without_waiters_when_notificat
error_message="closed",
)
- retire.assert_awaited_once_with(session, detail="stream_incomplete")
+ retire.assert_awaited_once_with(session, detail="stream_incomplete", response_events_seen=0)
@pytest.mark.asyncio
diff --git a/tests/unit/test_proxy_utils.py b/tests/unit/test_proxy_utils.py
index f5d77f1a88..3722c7131b 100644
--- a/tests/unit/test_proxy_utils.py
+++ b/tests/unit/test_proxy_utils.py
@@ -454,6 +454,16 @@ def test_account_selection_recovery_sleep_treats_workspace_spend_cap_as_recovera
assert _account_selection_recovery_sleep_seconds(selection) == 30.0
+def test_account_selection_recovery_sleep_retries_hard_affinity_owner_briefly():
+ selection = AccountSelection(
+ account=None,
+ error_message="Hard affinity owner account is unavailable",
+ error_code="hard_affinity_saturated",
+ )
+
+ assert _account_selection_recovery_sleep_seconds(selection) == 2.0
+
+
def test_account_selection_recovery_sleep_ignores_generic_no_available_accounts():
selection = AccountSelection(account=None, error_message="No available accounts", error_code="no_accounts")
@@ -32068,6 +32078,100 @@ async def test_response_create_admission_session_gate_timeout_returns_stable_rea
assert request_state.response_create_admission is None
+@pytest.mark.asyncio
+async def test_response_create_admission_stuck_gate_retire_ignores_draining_pending(monkeypatch):
+ settings = _make_proxy_settings()
+ settings.proxy_response_create_limit = 64
+ settings.proxy_admission_wait_timeout_seconds = 0.01
+ settings.http_responses_session_bridge_stuck_gate_retire_after_seconds = 1.0
+ service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder()))
+ blocked_request = proxy_service._WebSocketRequestState(
+ request_id="ws_req_gate_timeout_with_draining",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ )
+ stale_gate_holder = proxy_service._WebSocketRequestState(
+ request_id="req_stale_gate_holder",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ transport="http",
+ awaiting_response_created=True,
+ response_create_gate_acquired=True,
+ response_create_gate_wait_started_at=0.0,
+ )
+ active_request = proxy_service._WebSocketRequestState(
+ request_id="req_active_sibling",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ transport="http",
+ awaiting_response_created=True,
+ response_create_gate_acquired=True,
+ response_event_count=1,
+ )
+ draining_request = proxy_service._WebSocketRequestState(
+ request_id="req_draining_not_stale",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ transport="http",
+ draining_until_terminal=True,
+ response_event_count=1,
+ )
+ response_create_gate = asyncio.Semaphore(1)
+ await response_create_gate.acquire()
+ bridge_session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-stuck-gate-draining", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=_make_account("acc_stuck_gate_draining"),
+ upstream=AsyncMock(),
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([draining_request, active_request, stale_gate_holder]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=response_create_gate,
+ queued_request_count=2,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ )
+ retire_stale = AsyncMock()
+ fail_stale = AsyncMock()
+
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
+ monkeypatch.setattr(service, "_retire_stale_pending_http_bridge_session", retire_stale)
+ monkeypatch.setattr(service, "_fail_stale_http_bridge_pending_requests", fail_stale)
+
+ try:
+ with pytest.raises(proxy_module.ProxyResponseError) as exc_info:
+ await service._acquire_request_state_response_create_admission(
+ blocked_request,
+ response_create_gate=response_create_gate,
+ bridge_session=bridge_session,
+ )
+ finally:
+ response_create_gate.release()
+
+ exc = _assert_proxy_response_error(exc_info.value)
+ assert _proxy_error_code(exc) == "response_create_gate_timeout"
+ retire_stale.assert_not_awaited()
+ fail_stale.assert_awaited_once_with(
+ bridge_session,
+ [stale_gate_holder],
+ detail="response_create_gate_timeout_stuck_pending",
+ )
+
+
@pytest.mark.asyncio
async def test_response_create_admission_waits_on_session_gate_before_shared_capacity(monkeypatch):
settings = _make_proxy_settings()
@@ -33392,8 +33496,8 @@ async def fake_stream_with_retry(
assert calls == [(payload, "acc_doc")]
-def test_classify_upstream_close_rejected_only_for_clean_close_before_any_response_event():
- assert proxy_service._classify_upstream_close(1000, response_events_seen=0) == "rejected"
+def test_classify_upstream_close_clean_for_clean_close_before_any_response_event():
+ assert proxy_service._classify_upstream_close(1000, response_events_seen=0) == "clean"
assert proxy_service._classify_upstream_close(1000, response_events_seen=1) == "transient"
assert proxy_service._classify_upstream_close(1011, response_events_seen=0) == "transient"
@@ -33829,31 +33933,14 @@ async def close_old_upstream(*_args: object, **_kwargs: object) -> None:
kind="stream",
acquired_at=2.0,
)
- allow_reacquire = asyncio.Event()
- reacquire_started = asyncio.Event()
release_started = asyncio.Event()
finish_release = asyncio.Event()
- async def acquire_account_lease(*_args: object, **_kwargs: object) -> proxy_service.AccountLease:
- reacquire_started.set()
- await allow_reacquire.wait()
- return reacquired_lease
-
monkeypatch.setattr(proxy_service, "get_settings_cache", lambda: _SettingsCache(settings))
monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
- monkeypatch.setattr(proxy_service.time, "monotonic", lambda: 10.0)
- monkeypatch.setattr(
- service._load_balancer,
- "select_account",
- AsyncMock(
- return_value=AccountSelection(
- account=account,
- error_message=None,
- lease=reconnect_lease,
- )
- ),
- )
- monkeypatch.setattr(service._load_balancer, "acquire_account_lease", acquire_account_lease)
+ # Keep the deterministic service clock local; mutating the shared time
+ # module also freezes asyncio's event-loop timers.
+ monkeypatch.setattr(proxy_service, "time", SimpleNamespace(monotonic=lambda: 10.0))
async def release_account_lease_side_effect(released_lease: proxy_service.AccountLease) -> None:
assert released_lease is reacquired_lease
@@ -33889,28 +33976,19 @@ async def release_account_lease_side_effect(released_lease: proxy_service.Accoun
idle_ttl_seconds=30.0,
)
- reconnect_task = asyncio.create_task(service._reconnect_http_bridge_session(session, request_state=request_state))
- await reconnect_before_swap.wait()
-
- async def reacquire_under_pending_lock() -> None:
- async with session.pending_lock:
- await service._ensure_http_bridge_session_stream_lease_locked(session, request_state=request_state)
+ # Model an idle-session lease being reacquired immediately before the
+ # reconnect handoff. The reconnect must release that superseded lease
+ # while retaining the newly selected lease.
+ async def select_with_reacquire(*_args: object, **_kwargs: object) -> AccountSelection:
+ session.account_lease = reacquired_lease
+ return AccountSelection(account=account, error_message=None, lease=reconnect_lease)
- reacquire_task = asyncio.create_task(reacquire_under_pending_lock())
- await reacquire_started.wait()
+ monkeypatch.setattr(service, "_select_account_with_budget_for_stream", select_with_reacquire)
+ reconnect_task = asyncio.create_task(service._reconnect_http_bridge_session(session, request_state=request_state))
allow_old_upstream_close.set()
- await asyncio.sleep(0)
- allow_reacquire.set()
- await reacquire_task
- await release_started.wait()
- reconnect_task.cancel()
- reconnect_task.cancel()
- await asyncio.sleep(0)
- assert not reconnect_task.done()
-
+ await asyncio.wait_for(release_started.wait(), timeout=2)
finish_release.set()
- with pytest.raises(asyncio.CancelledError):
- await reconnect_task
+ await reconnect_task
assert session.account_lease is reconnect_lease
assert session.upstream is new_upstream
@@ -34008,6 +34086,101 @@ async def test_reconnect_http_bridge_security_rebind_clears_previous_response_st
assert "x-codex-turn-state" not in {header.lower() for header in session.headers}
+@pytest.mark.asyncio
+async def test_reconnect_http_bridge_session_restarts_reader_before_local_close(monkeypatch):
+ settings = _make_proxy_settings()
+ service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder()))
+ account = _make_account("acc_bridge_reader_handoff")
+ old_upstream = AsyncMock()
+ new_upstream = SimpleNamespace(response_header=lambda _name: None)
+ old_reader_started = asyncio.Event()
+ old_reader_cancelled = asyncio.Event()
+ replacement_reader_started = asyncio.Event()
+ release_replacement_reader = asyncio.Event()
+ session_holder = {}
+
+ monkeypatch.setattr(proxy_service, "get_settings_cache", lambda: _SettingsCache(settings))
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
+ monkeypatch.setattr(proxy_service.time, "monotonic", lambda: 10.0)
+ monkeypatch.setattr(
+ service._load_balancer,
+ "select_account",
+ AsyncMock(return_value=AccountSelection(account=account, error_message=None)),
+ )
+ monkeypatch.setattr(service, "_ensure_fresh_with_budget", AsyncMock(return_value=account))
+
+ async def blocked_old_reader() -> None:
+ old_reader_started.set()
+ try:
+ await asyncio.Event().wait()
+ except asyncio.CancelledError:
+ old_reader_cancelled.set()
+ raise
+ finally:
+ session_holder["session"].closed = True
+
+ async def close_old_upstream() -> None:
+ assert old_reader_cancelled.is_set()
+
+ async def replacement_reader(target_session) -> None:
+ assert target_session.upstream is new_upstream
+ replacement_reader_started.set()
+ await release_replacement_reader.wait()
+
+ async def open_replacement(*_args, **_kwargs):
+ # Reconnect keeps the bridge closed until the replacement socket has
+ # been installed; the handoff publishes it open only after connect.
+ assert session_holder["session"].closed is True
+ return new_upstream
+
+ old_upstream.close.side_effect = close_old_upstream
+ monkeypatch.setattr(service, "_relay_http_bridge_upstream_messages", replacement_reader)
+ monkeypatch.setattr(service, "_open_upstream_websocket_with_budget", open_replacement)
+ old_reader = asyncio.create_task(blocked_old_reader())
+ await old_reader_started.wait()
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_bridge_reader_handoff",
+ model="gpt-5.5",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=10.0,
+ )
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("prompt_cache", "bridge-reader-handoff", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(key="bridge-reader-handoff"),
+ request_model="gpt-5.5",
+ account=account,
+ upstream=old_upstream,
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([request_state]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=1,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ upstream_reader=old_reader,
+ )
+ session_holder["session"] = session
+
+ try:
+ await service._reconnect_http_bridge_session(session, request_state=request_state, restart_reader=True)
+ await asyncio.wait_for(replacement_reader_started.wait(), timeout=1.0)
+
+ assert old_reader.cancelled()
+ assert session.upstream is new_upstream
+ assert session.upstream_reader is not None
+ assert session.upstream_reader is not old_reader
+ assert session.upstream_reader.done() is False
+ old_upstream.close.assert_awaited_once()
+ finally:
+ release_replacement_reader.set()
+ replacement_task = session.upstream_reader
+ if replacement_task is not None:
+ await replacement_task
+
+
@pytest.mark.asyncio
async def test_reconnect_http_bridge_session_fails_over_after_repeated_401_refresh_retry(monkeypatch):
settings = _make_proxy_settings()
@@ -36280,15 +36453,151 @@ async def test_http_bridge_session_events_keepalive_backstop(monkeypatch):
finally:
await events.aclose()
- assert len(collected) == 3, f"Expected 3 events (2 keepalives + stream_idle_timeout), got {len(collected)}"
+ assert len(collected) == 2, f"Expected 2 events (1 keepalive + stream_idle_timeout), got {len(collected)}"
assert collected[0] == proxy_service.CODEX_KEEPALIVE_FRAME
- assert collected[1] == proxy_service.CODEX_KEEPALIVE_FRAME
- last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[2]))
+ last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[1]))
assert last["type"] == "response.failed"
assert cast(dict[str, object], last["response"])["status"] == "failed"
assert cast(dict[str, object], cast(dict[str, object], last["response"])["error"])["code"] == "stream_idle_timeout"
+@pytest.mark.asyncio
+async def test_http_bridge_session_events_retries_silent_pre_response_once(monkeypatch):
+ request_logs = _RequestLogsRecorder()
+ service = proxy_service.ProxyService(_repo_factory(request_logs))
+ settings = _make_proxy_settings()
+ settings.sse_keepalive_interval_seconds = 0.001
+ settings.stream_idle_timeout_seconds = 7200.0
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_bridge_idle_retry",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ response_id=None,
+ event_queue=asyncio.Queue(),
+ request_text='{"type":"response.create"}',
+ transport="http",
+ )
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("prompt_cache", "bridge-idle-retry", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=_make_account("acc_bridge_idle_retry"),
+ upstream=AsyncMock(),
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque(),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=0,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ )
+ retry_precreated = AsyncMock(side_effect=[True, False])
+
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
+ monkeypatch.setattr(proxy_service, "_STREAM_KEEPALIVE_MAX_COUNT", 2)
+ monkeypatch.setattr(proxy_service, "_HTTP_BRIDGE_STARTUP_KEEPALIVE_GRACE_SECONDS", 0.001)
+ monkeypatch.setattr(service, "_submit_http_bridge_request", AsyncMock())
+ monkeypatch.setattr(service, "_detach_http_bridge_request", AsyncMock())
+ monkeypatch.setattr(service, "_retry_http_bridge_precreated_request", retry_precreated)
+
+ events = service._stream_http_bridge_session_events(
+ session,
+ request_state=request_state,
+ text_data='{"type":"response.create"}',
+ queue_limit=10,
+ propagate_http_errors=False,
+ downstream_turn_state=None,
+ )
+ collected: list[str] = []
+ try:
+ async for event in events:
+ collected.append(event)
+ finally:
+ await events.aclose()
+
+ assert len(collected) == 3
+ assert collected[:2] == [proxy_service.CODEX_KEEPALIVE_FRAME] * 2
+ last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[-1]))
+ assert cast(dict[str, object], cast(dict[str, object], last["response"])["error"])["code"] == "stream_idle_timeout"
+ assert retry_precreated.await_count == 2
+ assert all(attempt.kwargs == {"restart_reader": True} for attempt in retry_precreated.await_args_list)
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_session_events_keeps_alive_during_retry_circuit_cooldown(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ request_logs = _RequestLogsRecorder()
+ service = proxy_service.ProxyService(_repo_factory(request_logs))
+ settings = _make_proxy_settings()
+ monkeypatch.setattr(proxy_service, "_STREAM_KEEPALIVE_MAX_COUNT", 1)
+ settings.sse_keepalive_interval_seconds = 0.001
+ settings.stream_idle_timeout_seconds = 1.0
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_bridge_circuit_keepalive",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=time.monotonic(),
+ response_id=None,
+ event_queue=asyncio.Queue(),
+ request_text='{"type":"response.create"}',
+ fresh_upstream_request_text='{"type":"response.create"}',
+ fresh_upstream_request_is_retry_safe=True,
+ transport="http",
+ )
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-circuit-keepalive", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=_make_account("acc_bridge_circuit_keepalive"),
+ upstream=AsyncMock(),
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([request_state]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=1,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ )
+ retry_precreated = AsyncMock(side_effect=[False, True, False])
+ retry_cooldown = AsyncMock(side_effect=[0.1, 0.1, 0.0, 0.0])
+
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
+ monkeypatch.setattr(service, "_submit_http_bridge_request", AsyncMock())
+ monkeypatch.setattr(service, "_detach_http_bridge_request", AsyncMock())
+ monkeypatch.setattr(service, "_retry_http_bridge_precreated_request", retry_precreated)
+ monkeypatch.setattr(service, "_http_bridge_precreated_retry_cooldown_seconds", retry_cooldown)
+
+ events = service._stream_http_bridge_session_events(
+ session,
+ request_state=request_state,
+ text_data='{"type":"response.create"}',
+ queue_limit=10,
+ propagate_http_errors=False,
+ downstream_turn_state=None,
+ )
+ collected: list[str] = []
+ try:
+ async for event in events:
+ collected.append(event)
+ finally:
+ await events.aclose()
+
+ assert len(collected) >= 2
+ assert collected[:-1] == [proxy_service.CODEX_KEEPALIVE_FRAME] * (len(collected) - 1)
+ last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[-1]))
+ assert cast(dict[str, object], cast(dict[str, object], last["response"])["error"])["code"] == "stream_idle_timeout"
+ assert retry_precreated.await_count >= 1
+ assert retry_cooldown.await_count >= 1
+
+
@pytest.mark.asyncio
async def test_http_bridge_session_events_keepalive_backstop_respects_idle_timeout(monkeypatch):
request_logs = _RequestLogsRecorder()
@@ -36347,9 +36656,9 @@ async def test_http_bridge_session_events_keepalive_backstop_respects_idle_timeo
finally:
await events.aclose()
- assert len(collected) == 6
- assert collected[:5] == [proxy_service.CODEX_KEEPALIVE_FRAME] * 5
- last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[5]))
+ assert len(collected) == 2
+ assert collected[0] == proxy_service.CODEX_KEEPALIVE_FRAME
+ last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[1]))
assert last["type"] == "response.failed"
assert cast(dict[str, object], cast(dict[str, object], last["response"])["error"])["code"] == "stream_idle_timeout"
@@ -36410,16 +36719,14 @@ async def test_http_bridge_session_events_keepalive_backstop_with_response_id(mo
finally:
await events.aclose()
- assert len(collected) == 3, (
- f"Expected 3 events (2 response.in_progress + stream_idle_timeout), got {len(collected)}"
+ assert len(collected) == 2, (
+ f"Expected 2 events (1 response.in_progress + stream_idle_timeout), got {len(collected)}"
)
- first = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[0]))
- assert first["type"] == "response.in_progress"
- assert cast(dict[str, object], first["response"])["id"] == "resp_bridge_backstop_codex"
- second = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[1]))
- assert second["type"] == "response.in_progress"
- assert cast(dict[str, object], second["response"])["id"] == "resp_bridge_backstop_codex"
- last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[2]))
+ for event_block in collected[:1]:
+ event = cast(dict[str, object], proxy_service.parse_sse_data_json(event_block))
+ assert event["type"] == "response.in_progress"
+ assert cast(dict[str, object], event["response"])["id"] == "resp_bridge_backstop_codex"
+ last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[1]))
assert last["type"] == "response.failed"
assert cast(dict[str, object], last["response"])["status"] == "failed"
assert cast(dict[str, object], cast(dict[str, object], last["response"])["error"])["code"] == "stream_idle_timeout"
@@ -36477,14 +36784,15 @@ async def test_http_bridge_session_events_keepalive_backstop_uses_replay_downstr
try:
async for event in events:
collected.append(event)
- if len(collected) >= 3:
+ if len(collected) >= 2:
break
finally:
await events.aclose()
+ assert len(collected) == 2
first = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[0]))
assert cast(dict[str, object], first["response"])["id"] == "resp_created_then_closed"
- last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[2]))
+ last = cast(dict[str, object], proxy_service.parse_sse_data_json(collected[1]))
assert cast(dict[str, object], last["response"])["id"] == "resp_created_then_closed"
assert cast(dict[str, object], cast(dict[str, object], last["response"])["error"])["code"] == "stream_idle_timeout"
@@ -36682,7 +36990,7 @@ async def capture_send_text(_text: str) -> None:
@pytest.mark.asyncio
-async def test_retry_http_bridge_precreated_request_suppresses_retry_for_rejected_close():
+async def test_retry_http_bridge_precreated_request_suppresses_retry_for_rejected_close(monkeypatch):
request_logs = _RequestLogsRecorder()
service = proxy_service.ProxyService(_repo_factory(request_logs))
request_state = proxy_service._WebSocketRequestState(
@@ -36712,6 +37020,14 @@ async def test_retry_http_bridge_precreated_request_suppresses_retry_for_rejecte
last_upstream_close_code=1000,
)
+ async def rejected_reconnect(*_args: object, **_kwargs: object) -> None:
+ raise proxy_module.ProxyResponseError(
+ 502,
+ openai_error("upstream_rejected_input", "Upstream rejected pre-created response (close_code=1000)"),
+ )
+
+ monkeypatch.setattr(service, "_reconnect_http_bridge_session", rejected_reconnect)
+
retried = await service._retry_http_bridge_precreated_request(session)
assert retried is False
@@ -36720,6 +37036,296 @@ async def test_retry_http_bridge_precreated_request_suppresses_retry_for_rejecte
assert "close_code=1000" in (request_state.error_message_override or "")
+@pytest.mark.asyncio
+async def test_http_bridge_prewarm_completion_does_not_clear_retry_circuit(monkeypatch):
+ service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder()))
+ clear_retry_circuit = AsyncMock()
+ account = _make_account("acc_prewarm_circuit")
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_prewarm_circuit",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ response_id="resp_prewarm_circuit",
+ event_queue=asyncio.Queue(),
+ request_text='{"type":"response.create"}',
+ transport="http",
+ request_kind="prewarm",
+ skip_request_log=True,
+ )
+ upstream = AsyncMock()
+ upstream.archive_received = lambda _message: None
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-prewarm-circuit", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=account,
+ upstream=upstream,
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([request_state]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=1,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ )
+ monkeypatch.setattr(service, "_clear_http_bridge_retry_circuit", clear_retry_circuit)
+ monkeypatch.setattr(service, "_finalize_websocket_request_state", AsyncMock())
+
+ await service._process_http_bridge_upstream_text(
+ session,
+ json.dumps(
+ {
+ "type": "response.completed",
+ "response": {
+ "id": "resp_prewarm_circuit",
+ "status": "completed",
+ },
+ },
+ separators=(",", ":"),
+ ),
+ )
+
+ clear_retry_circuit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_http_bridge_reader_failure_records_clean_close_circuit_with_admission_waiter(monkeypatch):
+ service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder()))
+ record_failure = AsyncMock()
+ fail_pending = AsyncMock()
+ retire_stale = AsyncMock()
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_clean_close_with_waiter",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ awaiting_response_created=True,
+ transport="http",
+ )
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-clean-close-waiter", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=_make_account("acc_clean_close_waiter"),
+ upstream=AsyncMock(),
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([request_state]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=1,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ admission_waiter_count=1,
+ last_upstream_close_code=1000,
+ )
+ monkeypatch.setattr(service, "_record_http_bridge_retry_circuit_failure", record_failure)
+ monkeypatch.setattr(service, "_fail_pending_websocket_requests", fail_pending)
+ monkeypatch.setattr(service, "_retire_stale_pending_http_bridge_session", retire_stale)
+
+ retired = await service._fail_http_bridge_reader_and_maybe_retire(
+ session,
+ error_code="stream_incomplete",
+ error_message="HTTP bridge upstream closed cleanly before response.completed",
+ )
+
+ assert retired is False
+ record_failure.assert_awaited_once_with(session, detail="clean_close")
+ fail_pending.assert_awaited_once()
+ retire_stale.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_retry_http_bridge_precreated_request_propagates_reader_restart(monkeypatch):
+ service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder()))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_bridge_external_idle_retry",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ awaiting_response_created=True,
+ request_text='{"type":"response.create"}',
+ )
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("prompt_cache", "bridge-external-idle-retry", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=_make_account("acc_bridge_external_idle_retry"),
+ upstream=AsyncMock(),
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([request_state]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=1,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ )
+ reconnect = AsyncMock(return_value=None)
+ monkeypatch.setattr(service, "_reconnect_http_bridge_session", reconnect)
+
+ assert await service._retry_http_bridge_precreated_request(session, restart_reader=True) is True
+
+ reconnect.assert_awaited_once_with(
+ session,
+ request_state=request_state,
+ restart_reader=True,
+ )
+ cast(AsyncMock, session.upstream.send_text).assert_awaited_once_with(request_state.request_text)
+
+
+@pytest.mark.asyncio
+async def test_retry_http_bridge_clean_close_keeps_hard_continuation_on_same_account(monkeypatch):
+ service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder()))
+ account = _make_account("acc_bridge_clean_continuation")
+ original_payload = {
+ "type": "response.create",
+ "model": "gpt-5.1",
+ "previous_response_id": "resp_anchor",
+ "input": [{"role": "user", "content": "continue"}],
+ }
+ fresh_payload = dict(original_payload)
+ fresh_payload.pop("previous_response_id")
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_bridge_clean_continuation",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ awaiting_response_created=True,
+ request_text=json.dumps(original_payload, separators=(",", ":")),
+ previous_response_id="resp_anchor",
+ preferred_account_id=account.id,
+ proxy_injected_previous_response_id=True,
+ fresh_upstream_request_text=json.dumps(fresh_payload, separators=(",", ":")),
+ fresh_upstream_request_is_retry_safe=True,
+ )
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-clean-continuation", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=account,
+ upstream=AsyncMock(),
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([request_state]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=1,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ last_upstream_close_code=1000,
+ )
+ reconnect = AsyncMock(return_value=None)
+ monkeypatch.setattr(service, "_reconnect_http_bridge_session", reconnect)
+
+ assert await service._retry_http_bridge_precreated_request(session) is True
+
+ reconnect.assert_awaited_once_with(session, request_state=request_state, require_same_account=True)
+ cast(AsyncMock, session.upstream.send_text).assert_awaited_once_with(request_state.request_text)
+
+
+@pytest.mark.asyncio
+async def test_retry_http_bridge_clean_close_keeps_hard_turn_state_on_same_account(monkeypatch):
+ service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder()))
+ account = _make_account("acc_bridge_clean_turn_state")
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_bridge_clean_turn_state",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ awaiting_response_created=True,
+ request_text='{"type":"response.create","input":"turn"}',
+ hard_continuity_anchor=True,
+ )
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("turn_state_header", "turn-state-clean-close", None),
+ headers={"x-codex-turn-state": "turn-state-clean-close"},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=account,
+ upstream=AsyncMock(),
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([request_state]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=1,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ last_upstream_close_code=1000,
+ )
+ reconnect = AsyncMock(return_value=None)
+ monkeypatch.setattr(service, "_reconnect_http_bridge_session", reconnect)
+
+ assert await service._retry_http_bridge_precreated_request(session) is True
+
+ reconnect.assert_awaited_once_with(session, request_state=request_state, require_same_account=True)
+ cast(AsyncMock, session.upstream.send_text).assert_awaited_once_with(request_state.request_text)
+
+
+@pytest.mark.asyncio
+async def test_retry_http_bridge_clean_close_allows_one_additional_retry(monkeypatch):
+ settings = _make_proxy_settings()
+ monkeypatch.setattr(proxy_http_bridge_request_submit, "_HTTP_BRIDGE_CLEAN_CLOSE_RETRY_MAX_COUNT", 1)
+ monkeypatch.setattr(proxy_http_bridge_request_submit, "_HTTP_BRIDGE_CLEAN_CLOSE_RETRY_JITTER_MAX_SECONDS", 0.0)
+ service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder()))
+ request_state = proxy_service._WebSocketRequestState(
+ request_id="req_bridge_clean_close_second_retry",
+ model="gpt-5.1",
+ service_tier=None,
+ reasoning_effort=None,
+ api_key_reservation=None,
+ started_at=0.0,
+ awaiting_response_created=True,
+ request_text='{"type":"response.create"}',
+ )
+ upstream = AsyncMock()
+ session = proxy_service._HTTPBridgeSession(
+ key=proxy_service._HTTPBridgeSessionKey("session_header", "bridge-clean-close-second-retry", None),
+ headers={},
+ affinity=proxy_service._AffinityPolicy(),
+ request_model="gpt-5.1",
+ account=_make_account("acc_bridge_clean_close_second_retry"),
+ upstream=upstream,
+ upstream_control=proxy_service._WebSocketUpstreamControl(),
+ pending_requests=deque([request_state]),
+ pending_lock=anyio.Lock(),
+ response_create_gate=asyncio.Semaphore(1),
+ queued_request_count=1,
+ last_used_at=0.0,
+ idle_ttl_seconds=30.0,
+ last_upstream_close_code=1000,
+ )
+ reconnect = AsyncMock(return_value=None)
+ monkeypatch.setattr(proxy_service, "get_settings", lambda: settings)
+ monkeypatch.setattr(service, "_reconnect_http_bridge_session", reconnect)
+
+ assert await service._retry_http_bridge_precreated_request(session) is True
+ assert request_state.replay_count == 1
+ session.last_upstream_close_code = 1000
+ session.last_upstream_close_generation += 1
+
+ assert await service._retry_http_bridge_precreated_request(session) is True
+ assert request_state.replay_count == 2
+ assert request_state.clean_close_replay_count == 1
+ assert reconnect.await_count == 2
+ assert upstream.send_text.await_count == 2
+
+ assert await service._retry_http_bridge_precreated_request(session) is False
+ assert reconnect.await_count == 2
+
+
@pytest.mark.asyncio
async def test_retry_http_bridge_precreated_request_suppresses_retry_after_response_event(monkeypatch):
request_logs = _RequestLogsRecorder()
@@ -36820,11 +37426,18 @@ async def test_retry_http_bridge_precreated_request_migrates_only_safe_initial_t
assert await service._retry_http_bridge_precreated_request(session) is True
- reconnect.assert_awaited_once_with(
- session,
- request_state=request_state,
- require_same_account=False,
- )
+ if file_owner_bound:
+ reconnect.assert_awaited_once_with(
+ session,
+ request_state=request_state,
+ require_same_account=False,
+ require_preferred_account=True,
+ )
+ else:
+ reconnect.assert_awaited_once_with(
+ session,
+ request_state=request_state,
+ )
assert request_state.preferred_account_id == expected_preferred_account_id
assert request_state.excluded_account_ids == expected_excluded_account_ids
assert session.upstream_turn_state == expected_turn_state
@@ -36873,13 +37486,12 @@ async def test_retry_http_bridge_precreated_request_keeps_hard_session_owner_bou
reconnect.assert_awaited_once_with(
session,
request_state=request_state,
- require_same_account=True,
)
assert request_state.preferred_account_id is None
- assert request_state.excluded_account_ids == set()
- assert session.upstream_turn_state == "hard-turn-state"
- assert session.downstream_turn_state == "hard-turn-state"
- assert session.headers["x-codex-turn-state"] == "hard-turn-state"
+ assert request_state.excluded_account_ids == {account.id}
+ assert session.upstream_turn_state is None
+ assert session.downstream_turn_state is None
+ assert "x-codex-turn-state" not in session.headers
def test_websocket_safe_headers_clear_stale_turn_state_when_replacement_has_none() -> None:
@@ -37264,7 +37876,6 @@ async def capture_send_text(_text: str) -> None:
reconnect.assert_awaited_once_with(
session,
request_state=request_state,
- require_same_account=False,
)
send_text.assert_awaited_once_with('{"type":"response.create","model":"gpt-5.1","input":"retry"}')
assert send_request_ids == ["archive_bridge_created_no_output"]
@@ -38019,6 +38630,7 @@ def make_state(request_id: str) -> "proxy_service._WebSocketRequestState":
@pytest.mark.asyncio
async def test_submit_http_bridge_request_reinlines_final_text(monkeypatch):
service = proxy_service.ProxyService.__new__(proxy_service.ProxyService)
+ proxy_service._initialize_http_bridge_retry_circuit(service)
original_text = json.dumps(
{
"type": "response.create",
@@ -38106,6 +38718,7 @@ async def capture_send_text(_text: str) -> None:
@pytest.mark.asyncio
async def test_submit_http_bridge_network_send_failure_is_neutral_and_not_replayed(monkeypatch):
service = proxy_service.ProxyService.__new__(proxy_service.ProxyService)
+ proxy_service._initialize_http_bridge_retry_circuit(service)
request_state = proxy_service._WebSocketRequestState(
request_id="req_submit_network_failure",
model="gpt-5.5",
@@ -38179,6 +38792,7 @@ async def cleanup(*_args: object, **_kwargs: object) -> None:
@pytest.mark.asyncio
async def test_submit_http_bridge_request_checks_queue_before_inlining(monkeypatch):
service = proxy_service.ProxyService.__new__(proxy_service.ProxyService)
+ proxy_service._initialize_http_bridge_retry_circuit(service)
request_state = proxy_service._WebSocketRequestState(
request_id="req_submit_queue_full_inline",
model="gpt-5.5",
diff --git a/tests/unit/test_proxy_websocket_client.py b/tests/unit/test_proxy_websocket_client.py
index 034d0ca6c2..6dcd3af3f5 100644
--- a/tests/unit/test_proxy_websocket_client.py
+++ b/tests/unit/test_proxy_websocket_client.py
@@ -52,6 +52,8 @@ class _UnexpectedHttpClient:
class _FakeConnection:
+ connection_lost_waiter: asyncio.Future[object]
+
def __init__(self, *, subprotocol: str | None = None) -> None:
self.sent: list[str | bytes] = []
self.closed = False
@@ -67,6 +69,18 @@ async def close(self, code: int = 1000, reason: str = "") -> None:
self.closed = True
+@pytest.mark.asyncio
+async def test_websockets_response_websocket_consumes_connection_lost_waiter_error():
+ connection = _FakeConnection()
+ connection.connection_lost_waiter = asyncio.get_running_loop().create_future()
+
+ WebsocketsUpstreamWebSocket(cast(Any, connection))
+ connection.connection_lost_waiter.set_exception(RuntimeError("keepalive ping timeout"))
+ await asyncio.sleep(0)
+
+ assert connection.connection_lost_waiter.exception() is not None
+
+
async def _local_proxy_tunnel_handler(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
diff --git a/tests/unit/test_settings_reference.py b/tests/unit/test_settings_reference.py
index 04633e7c3c..8f5c828cc8 100644
--- a/tests/unit/test_settings_reference.py
+++ b/tests/unit/test_settings_reference.py
@@ -45,7 +45,7 @@ def _isolated_settings(**overrides: Any) -> Settings:
# number when fields are removed; never raise it without a simplicity-budget
# discussion — every new CODEX_LB_* setting needs a why-not-a-default
# justification per CONTRIBUTING.md's simplicity gates.
-MAX_SETTINGS_FIELDS = 115
+MAX_SETTINGS_FIELDS = 116
def test_generated_settings_reference_matches_code() -> None:
diff --git a/tests/unit/test_sse.py b/tests/unit/test_sse.py
index a0f7945e1e..69a1e97a53 100644
--- a/tests/unit/test_sse.py
+++ b/tests/unit/test_sse.py
@@ -3,6 +3,7 @@
import asyncio
import json
from collections.abc import AsyncIterator
+from typing import Any, cast
import pytest
@@ -50,10 +51,38 @@ async def test_inject_sse_keepalives_no_pings_when_source_is_fast():
@pytest.mark.asyncio
async def test_inject_sse_keepalives_emits_pings_on_idle_gap():
- out = [chunk async for chunk in inject_sse_keepalives(_slow_agen(["a\n\n"], delay=0.25), 0.05)]
+ callbacks: list[str] = []
+ out = [
+ chunk
+ async for chunk in inject_sse_keepalives(
+ _slow_agen(["a\n\n"], delay=0.25),
+ 0.05,
+ on_keepalive=lambda: callbacks.append("sent"),
+ )
+ ]
assert out[-1] == "a\n\n"
assert SSE_KEEPALIVE_FRAME in out
assert out.count(SSE_KEEPALIVE_FRAME) >= 2
+ assert len(callbacks) == out.count(SSE_KEEPALIVE_FRAME)
+
+
+@pytest.mark.asyncio
+async def test_inject_sse_keepalives_cancels_idle_source_when_downstream_closes():
+ source_cancelled = asyncio.Event()
+
+ async def source() -> AsyncIterator[str]:
+ try:
+ await asyncio.Event().wait()
+ finally:
+ source_cancelled.set()
+ yield "" # pragma: no cover - keeps this a pending async generator
+
+ stream = inject_sse_keepalives(source(), 0.01)
+ assert await anext(stream) == SSE_KEEPALIVE_FRAME
+
+ await cast(Any, stream).aclose()
+
+ assert source_cancelled.is_set()
@pytest.mark.asyncio
diff --git a/tests/unit/test_sticky_session_cleanup_scheduler.py b/tests/unit/test_sticky_session_cleanup_scheduler.py
index 51d8ec870c..da18e40f36 100644
--- a/tests/unit/test_sticky_session_cleanup_scheduler.py
+++ b/tests/unit/test_sticky_session_cleanup_scheduler.py
@@ -58,6 +58,7 @@ async def test_cleanup_once_purges_prompt_cache_only(monkeypatch) -> None:
bridge_repo = AsyncMock()
bridge_repo.purge_closed_before = AsyncMock(return_value=2)
bridge_repo.purge_abandoned_before = AsyncMock(return_value=1)
+ bridge_repo.purge_retry_circuits_before = AsyncMock(return_value=3)
ring_service = AsyncMock()
ring_service.purge_stale_before = AsyncMock(return_value=0)
@@ -88,6 +89,7 @@ async def __aexit__(self, *args):
sticky_repo.purge_before.assert_not_called()
bridge_repo.purge_closed_before.assert_called_once()
bridge_repo.purge_abandoned_before.assert_called_once()
+ bridge_repo.purge_retry_circuits_before.assert_called_once()
ring_service.purge_stale_before.assert_called_once()
@@ -114,6 +116,7 @@ async def test_cleanup_once_skips_bridge_purge_when_schema_is_not_ready(monkeypa
bridge_repo = AsyncMock()
bridge_repo.purge_closed_before = AsyncMock(return_value=0)
bridge_repo.purge_abandoned_before = AsyncMock(return_value=0)
+ bridge_repo.purge_retry_circuits_before = AsyncMock(return_value=0)
ring_service = AsyncMock()
ring_service.purge_stale_before = AsyncMock(return_value=0)
@@ -148,6 +151,7 @@ async def __aexit__(self, *args):
sticky_repo.purge_prompt_cache_before.assert_called_once()
bridge_repo.purge_closed_before.assert_not_called()
bridge_repo.purge_abandoned_before.assert_not_called()
+ bridge_repo.purge_retry_circuits_before.assert_not_called()
ring_service.purge_stale_before.assert_called_once()
@@ -174,6 +178,7 @@ async def test_cleanup_once_purges_bridge_when_schema_exists_after_startup_flag_
bridge_repo = AsyncMock()
bridge_repo.purge_closed_before = AsyncMock(return_value=1)
bridge_repo.purge_abandoned_before = AsyncMock(return_value=0)
+ bridge_repo.purge_retry_circuits_before = AsyncMock(return_value=0)
ring_service = AsyncMock()
ring_service.purge_stale_before = AsyncMock(return_value=2)
@@ -204,6 +209,7 @@ async def __aexit__(self, *args):
sticky_repo.purge_prompt_cache_before.assert_called_once()
bridge_repo.purge_closed_before.assert_called_once()
bridge_repo.purge_abandoned_before.assert_called_once()
+ bridge_repo.purge_retry_circuits_before.assert_called_once()
ring_service.purge_stale_before.assert_called_once()
@@ -259,6 +265,7 @@ async def test_cleanup_once_gates_abandoned_purge_on_prompt_cache_reuse_ttl(monk
bridge_repo = AsyncMock()
bridge_repo.purge_closed_before = AsyncMock(return_value=0)
bridge_repo.purge_abandoned_before = AsyncMock(return_value=0)
+ bridge_repo.purge_retry_circuits_before = AsyncMock(return_value=0)
ring_service = AsyncMock()
ring_service.purge_stale_before = AsyncMock(return_value=0)