diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 0cb397f3322..4d75502dab4 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -1043,6 +1043,17 @@ def _classify_by_status( ) return result_fn(FailoverReason.overloaded, retryable=True) + # 408 Request Timeout — a transient timing failure the server itself flags + # as safe to retry (RFC 9110 §15.5.9), not a malformed request. Commonly + # emitted by reverse proxies sitting in front of self-hosted backends + # (llama.cpp / Ollama / vLLM) when a long generation outruns the proxy's + # request-read window. Route to the dedicated ``timeout`` reason (rebuild + # client + retry) instead of falling through to the generic 4xx bucket + # below, which would abort the turn on a retry-safe error the same way it + # aborts a 400 Bad Request. + if status_code == 408: + return result_fn(FailoverReason.timeout, retryable=True) + # Other 4xx — non-retryable if 400 <= status_code < 500: return result_fn( diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 3aff558062e..c336cc429a6 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -376,6 +376,26 @@ class TestClassifyApiError: result = classify_api_error(e) assert result.reason == FailoverReason.overloaded + def test_408_request_timeout_is_retryable_timeout(self): + """HTTP 408 Request Timeout is a transient timing failure the server + itself flags as safe to retry (RFC 9110 §15.5.9) — commonly emitted by + reverse proxies in front of self-hosted backends (llama.cpp / Ollama / + vLLM) when a long generation outruns the proxy's request-read window. + It must NOT fall into the generic 4xx bucket as a non-retryable + format_error, which would abort the turn on a retry-safe error.""" + e = MockAPIError("Request Timeout", status_code=408) + result = classify_api_error(e, provider="vllm") + assert result.reason == FailoverReason.timeout + assert result.retryable is True + + def test_400_bad_request_still_non_retryable_format_error(self): + """Guard the boundary: a genuine 400 Bad Request must remain a + non-retryable format_error and must not be swept up by the 408 branch.""" + e = MockAPIError("Bad Request", status_code=400) + result = classify_api_error(e) + assert result.reason == FailoverReason.format_error + assert result.retryable is False + def test_message_only_overloaded_without_status_is_overloaded(self): """Some Anthropic-compatible proxies surface 'overloaded' in the message with no 503/529 status_code. It must classify as overloaded