diff --git a/contextual_orchestrator/orchestrator.py b/contextual_orchestrator/orchestrator.py index 47bf590d3..a29afe8a7 100644 --- a/contextual_orchestrator/orchestrator.py +++ b/contextual_orchestrator/orchestrator.py @@ -2036,8 +2036,10 @@ def _is_omit_equivalent_control(key: str, value: Any) -> bool: return False -def _is_request_too_large_error(exc: BaseException) -> bool: - """Recognize request-size rejection through a bounded exception chain.""" +def _is_request_too_large_error( + exc: BaseException, *, follow_chain: bool = True +) -> bool: + """Recognize a request-size rejection, optionally following its cause chain.""" current: BaseException | None = exc seen: set[int] = set() for _ in range(_PROVIDER_ERROR_CHAIN_LIMIT): @@ -2053,6 +2055,8 @@ def _is_request_too_large_error(exc: BaseException) -> bool: ) ): return True + if not follow_chain: + return False if current.__cause__ is not None: current = current.__cause__ elif current.__suppress_context__: @@ -2068,7 +2072,11 @@ def _is_passthrough_failover_error( """Recognize failures proving that a passthrough request was not accepted.""" if isinstance(exc, _LocalProviderAdmissionTimeout): return True - if _is_request_too_large_error(exc): + if review_free_request and not isinstance( + exc, (ProviderUpstreamError, urllib.error.HTTPError) + ): + return False + if _is_request_too_large_error(exc, follow_chain=not review_free_request): return True current: BaseException | None = exc seen: set[int] = set() @@ -2114,6 +2122,8 @@ def _is_passthrough_failover_error( and current.errno == socket.EAI_AGAIN ): return True + if review_free_request: + return False if current.__cause__ is not None: current = current.__cause__ elif current.__suppress_context__: @@ -6392,8 +6402,13 @@ def proxy_completion( ) if _is_ambiguous_passthrough_transport_failure(exc) or ( review_free_request - and classified.provider_status is None - and classified.retryable + and ( + (classified.provider_status is None and classified.retryable) + or ( + not isinstance(exc, (ProviderUpstreamError, urllib.error.HTTPError)) + and (exc.__cause__ is not None or exc.__context__ is not None) + ) + ) ): # The candidate may already have applied the request. # Review-free completions have no idempotency proof, diff --git a/tests/test_passthrough_provider_failover.py b/tests/test_passthrough_provider_failover.py index 99f080984..688de79e9 100644 --- a/tests/test_passthrough_provider_failover.py +++ b/tests/test_passthrough_provider_failover.py @@ -395,6 +395,35 @@ def test_free_review_dns_failure_does_not_authorize_replay(wrapped: bool) -> Non assert [agent_id for agent_id, _ in client.calls] == ["primary_agent"] +@pytest.mark.parametrize("status", [404, 413]) +def test_free_review_wrapped_rejection_does_not_authorize_replay(status: int) -> None: + """A prior rejection in an exception chain cannot prove this send was rejected.""" + body = {"error": {"code": "model_not_found"}} if status == 404 else None + with _http_error(status, body) as prior: + failure = RuntimeError("synthetic current send outcome unknown") + failure.__cause__ = prior + 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()