Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions contextual_orchestrator/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2108,7 +2108,11 @@ def _is_passthrough_failover_error(
and _is_single_tool_call_limit_error(current)
):
return True
if isinstance(current, socket.gaierror) and current.errno == socket.EAI_AGAIN:
if (
not review_free_request
and isinstance(current, socket.gaierror)
and current.errno == socket.EAI_AGAIN
):
return True
if current.__cause__ is not None:
current = current.__cause__
Expand Down Expand Up @@ -6386,7 +6390,11 @@ def proxy_completion(
else None,
status=signal_status,
)
if _is_ambiguous_passthrough_transport_failure(exc):
if _is_ambiguous_passthrough_transport_failure(exc) or (
review_free_request
and classified.provider_status is None
and classified.retryable
):
# The candidate may already have applied the request.
# Review-free completions have no idempotency proof,
# so their request stops even with another candidate.
Expand Down
29 changes: 29 additions & 0 deletions tests/test_passthrough_provider_failover.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,35 @@ def test_free_review_bodyless_404_cannot_authorize_replay() -> None:
assert [agent_id for agent_id, _ in client.calls] == ["primary_agent"]


@pytest.mark.parametrize("wrapped", [False, True])
def test_free_review_dns_failure_does_not_authorize_replay(wrapped: bool) -> None:
"""A DNS failure may follow an earlier send within the candidate transport."""
dns = socket.gaierror(socket.EAI_AGAIN, "synthetic resolver failure")
failure = RuntimeError("synthetic unknown send boundary") if wrapped else dns
if wrapped:
failure.__cause__ = dns
client = SequencedProxyClient({
"primary_agent": failure,
"fallback_agent": {"model": "fallback-model", "choices": []},
})
orchestrator = _build(client)
orchestrator.agents = [
replace(agent, tags=(*agent.tags, "cost:free", "review"))
for agent in orchestrator.agents
]

with pytest.raises(ProviderUpstreamError) as caught:
orchestrator.proxy_completion({
"model": TaskOrchestrator.FREE_MODEL,
"messages": [{"role": "user", "content": "synthetic"}],
})

assert (caught.value.client_status, caught.value.error_code, caught.value.retryable) == (
502, "provider_outcome_unknown", False,
)
assert [agent_id for agent_id, _ in client.calls] == ["primary_agent"]


def test_virtual_passthrough_keeps_non_size_tool_errors_sticky() -> None:
"""A generic provider invalid_tools response must not hide a bad request."""
failure = _invalid_tools_error()
Expand Down