130 lines
5.2 KiB
Python
130 lines
5.2 KiB
Python
"""Factual summaries for transport-timeout errors with empty messages.
|
|
|
|
httpx timeout exceptions (``ReadTimeout``, ``ConnectTimeout``, ``PoolTimeout``,
|
|
``WriteTimeout``) stringify to an EMPTY string, and the OpenAI SDK's
|
|
``APITimeoutError`` stringifies to the generic "Request timed out.". When one
|
|
of these survives the retry loop, ``AIAgent._summarize_api_error`` fell through
|
|
to its ``raw[:500]`` fallback and produced a blank (or near-blank) summary —
|
|
the user saw ``"API call failed after 6 retries: "`` with nothing after the
|
|
colon, and an operator could not tell a TLS abort from a reset connection from
|
|
a deterministic self-inflicted timeout fire.
|
|
|
|
This module is a pure classifier (no network, no agent instance) that turns
|
|
the exception *type* into a factual, phase-specific message with the
|
|
configured timeout value embedded and the exact config knob to raise:
|
|
|
|
===================== =====================================================
|
|
Case Message
|
|
===================== =====================================================
|
|
Connect-phase timeout ``connect timeout: no connection established with the
|
|
provider``
|
|
Read/pool timeout ``read timeout: no response received within <N>s —
|
|
consider raising providers.<provider>.
|
|
request_timeout_seconds in ~/.hermes/config.yaml``
|
|
Non-timeout ``None`` (caller falls through to existing handling)
|
|
===================== =====================================================
|
|
|
|
Ported from block/buzz#4959 (``timeout_message`` pure fn over
|
|
``{is_connect, llm_timeout, phase}`` in ``crates/buzz-agent/src/llm.rs``),
|
|
adapted to hermes-agent's exception-type landscape and per-provider
|
|
``request_timeout_seconds`` config.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Optional
|
|
|
|
# Exception type names that indicate the CONNECT phase timed out: no TCP/TLS
|
|
# connection was ever established. Raising the read timeout will not help;
|
|
# the endpoint is unreachable or the connect window is too small.
|
|
_CONNECT_TIMEOUT_TYPES = frozenset({
|
|
"ConnectTimeout", # httpx
|
|
"ConnectTimeoutError", # urllib3
|
|
})
|
|
|
|
# Exception type names that indicate the connection was established but no
|
|
# (or no further) response bytes arrived within the read window. This is the
|
|
# deterministic self-inflicted case: a slow model generation legitimately
|
|
# exceeding the configured timeout looks identical to a network fault unless
|
|
# we say which timer fired.
|
|
_READ_TIMEOUT_TYPES = frozenset({
|
|
"ReadTimeout", # httpx
|
|
"WriteTimeout", # httpx
|
|
"PoolTimeout", # httpx
|
|
"TimeoutException", # httpx base class
|
|
"ReadTimeoutError", # urllib3
|
|
"APITimeoutError", # openai SDK ("Request timed out.")
|
|
})
|
|
|
|
|
|
def _resolve_configured_timeout(
|
|
provider: Optional[str], model: Optional[str]
|
|
) -> Optional[float]:
|
|
"""Best-effort lookup of the request timeout that governed the call.
|
|
|
|
Mirrors the resolution order in ``agent/chat_completion_helpers.py``:
|
|
per-model / per-provider ``request_timeout_seconds`` from config.yaml
|
|
wins; otherwise the ``HERMES_API_TIMEOUT`` env default (1800s). Returns
|
|
``None`` when nothing can be resolved (e.g. config unreadable in tests).
|
|
"""
|
|
if provider:
|
|
try:
|
|
from hermes_cli.timeouts import get_provider_request_timeout
|
|
cfg = get_provider_request_timeout(provider, model)
|
|
if cfg is not None:
|
|
return float(cfg)
|
|
except Exception:
|
|
pass
|
|
raw = os.environ.get("HERMES_API_TIMEOUT")
|
|
if raw:
|
|
try:
|
|
value = float(raw)
|
|
if value > 0:
|
|
return value
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def summarize_timeout_error(
|
|
error: BaseException,
|
|
provider: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""Return a factual timeout summary, or ``None`` for non-timeout errors.
|
|
|
|
Pure over the exception's type name (the exceptions themselves carry no
|
|
useful message — that is the bug). ``provider``/``model`` are optional
|
|
context used to embed the configured timeout value and name the exact
|
|
config knob; when absent the message still names the knob generically.
|
|
"""
|
|
name = type(error).__name__
|
|
|
|
if name in _CONNECT_TIMEOUT_TYPES:
|
|
return (
|
|
"connect timeout: no connection established with the provider "
|
|
"(endpoint unreachable or connect window too small — check the "
|
|
"base_url and network path)"
|
|
)
|
|
|
|
if name not in _READ_TIMEOUT_TYPES:
|
|
return None
|
|
|
|
timeout_s = _resolve_configured_timeout(provider, model)
|
|
if timeout_s is not None:
|
|
within = f"within {timeout_s:g}s"
|
|
else:
|
|
within = "within the configured request timeout"
|
|
|
|
if provider:
|
|
knob = f"`providers.{provider}.request_timeout_seconds`"
|
|
else:
|
|
knob = "`providers.<provider>.request_timeout_seconds`"
|
|
|
|
return (
|
|
f"read timeout: no response received {within} — the generation may "
|
|
f"legitimately need longer; consider raising {knob} in "
|
|
"`~/.hermes/config.yaml`"
|
|
)
|