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
25 changes: 20 additions & 5 deletions openviking/utils/model_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ def extract_metric_error_code(error: BaseException) -> str:
"connectionerror",
"connection refused",
"connection reset",
# Node-based gateways can forward errno tokens instead of prose transport
# errors. These are equivalent transient connection failures.
"econnrefused",
"econnreset",
"etimedout",
)

RETRYABLE_RATE_LIMIT_MARKERS = (
Expand All @@ -176,17 +181,25 @@ def extract_metric_error_code(error: BaseException) -> str:
RATE_LIMIT_RETRY_BASE_DELAY_SECONDS = 5.0
RATE_LIMIT_RETRY_MAX_DELAY_SECONDS = 120.0

# Pre-compile regex for numeric status-code patterns to avoid substring false positives
# (e.g. "413" matching inside request IDs like "d7c9130f344..." or "req-413-abcd").
# Pre-compile regex for numeric status-code patterns to avoid matching unrelated
# values (for example request IDs, token limits, or batch sizes).
_NUMERIC_PATTERN_RE: dict[str, re.Pattern] = {}
_HTTP_STATUS_SUFFIX = (
r"(?:status(?:\s+code)?|bad\s+request|unauthorized|forbidden|payload\s+too\s+large|"
r"too\s+many\s+requests|internal\s+server\s+error|bad\s+gateway|"
r"service\s+unavailable|gateway\s+timeout)"
)


def _get_numeric_pattern_re(pattern: str) -> re.Pattern:
if pattern not in _NUMERIC_PATTERN_RE:
escaped = re.escape(pattern)
_NUMERIC_PATTERN_RE[pattern] = re.compile(
rf"(?:\b(?:error\s*code|status(?:\s*code)?|http(?:\s*status)?|code)"
rf"\s*[:=]?\s*{escaped}(?!\w)|(?<![\w-]){escaped}(?![\w-]))"
rf"(?:^\s*{escaped}(?![\w-])|"
rf"\b(?:api\s*error|error\s*code|status(?:\s*code)?|"
rf"http(?:\s*(?:status|error))?|code)"
rf"\s*[:=]?\s*\(?{escaped}\)?(?!\w)|"
rf"(?<![\w-]){escaped}(?![\w-])(?=\s+{_HTTP_STATUS_SUFFIX}\b))"
)
return _NUMERIC_PATTERN_RE[pattern]

Expand All @@ -195,7 +208,9 @@ def _pattern_matches(text_lower: str, text_compact: str, pattern: str) -> bool:
"""Check if pattern matches in text, using token-aware matching for numeric patterns.

Numeric-only patterns (e.g. ``"413"``) must look like HTTP status codes, not
request ID fragments. Non-numeric patterns use plain substring matching as before.
request IDs or unrelated values. A status code is accepted at the start of the
message, after an explicit status label, or before ``status code``. Non-numeric
patterns use plain substring matching as before.
"""
if pattern.isdigit():
return bool(_get_numeric_pattern_re(pattern).search(text_lower)) or bool(
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/test_model_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,64 @@ def test_numeric_status_code_with_compact_error_code_context_still_matches():
assert classify_api_error(RuntimeError("Error code:413-Payload Too Large")) == (
ERROR_CLASS_INPUT_TOO_LARGE
)


@pytest.mark.parametrize(
"message",
[
"Validation error: max_tokens=500 is too high",
"Validation error: token budget 500 is too high",
"Configured batch size: 502 items",
"The response limit is 504 tokens",
],
)
def test_numeric_values_without_http_status_context_stay_unknown(message):
assert classify_api_error(RuntimeError(message)) == ERROR_CLASS_UNKNOWN


@pytest.mark.parametrize(
("message", "expected"),
[
("500 Internal Server Error", ERROR_CLASS_TRANSIENT),
("HTTP 500 Internal Server Error", ERROR_CLASS_TRANSIENT),
("OpenAI API error (500): request failed", ERROR_CLASS_TRANSIENT),
("Upstream returned 500 Internal Server Error", ERROR_CLASS_TRANSIENT),
("API error: 502 Bad Gateway", ERROR_CLASS_TRANSIENT),
("status code: 503 Service Unavailable", ERROR_CLASS_TRANSIENT),
("code: 503", ERROR_CLASS_TRANSIENT),
("504 status code", ERROR_CLASS_TRANSIENT),
("413 Payload Too Large", ERROR_CLASS_INPUT_TOO_LARGE),
("403 AccountQuotaExceeded", ERROR_CLASS_AUTH),
("Error code: 400 - invalid parameter", ERROR_CLASS_PERMANENT),
],
)
def test_numeric_http_status_context_still_classifies(message, expected):
assert classify_api_error(RuntimeError(message)) == expected


@pytest.mark.parametrize("errno", ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"])
def test_node_transport_errno_is_transient(errno):
assert classify_api_error(RuntimeError(f"connect {errno} upstream")) == ERROR_CLASS_TRANSIENT


def test_retry_sync_uses_status_context_and_node_errno():
validation_attempts = {"count": 0}

def invalid_request():
validation_attempts["count"] += 1
raise RuntimeError("Validation error: max_tokens=500 is too high")

with pytest.raises(RuntimeError):
retry_sync(invalid_request, max_retries=3, base_delay=0, jitter=False)
assert validation_attempts["count"] == 1

transport_attempts = {"count": 0}

def transient_request():
transport_attempts["count"] += 1
if transport_attempts["count"] == 1:
raise RuntimeError("connect ECONNRESET upstream")
return "ok"

assert retry_sync(transient_request, max_retries=3, base_delay=0, jitter=False) == "ok"
assert transport_attempts["count"] == 2
Loading