Skip to content

Commit 2c02dc6

Browse files
Lexus2016Hermes Evolution
andauthored
WIP: provider reliability fixes (#369)
- Fail fast on non-retryable client errors (#366) - Retry-After parsing + per-provider cooldown + fallback skip (#363) - Paid-subscription 403 as billing with fallback (#364) - Duplicate API request dump suppression (#367) Co-authored-by: Hermes Evolution <evolution@hermes.ai>
1 parent edb71b2 commit 2c02dc6

12 files changed

Lines changed: 3329 additions & 1256 deletions

agent/agent_runtime_helpers.py

Lines changed: 438 additions & 219 deletions
Large diffs are not rendered by default.

agent/chat_completion_helpers.py

Lines changed: 358 additions & 157 deletions
Large diffs are not rendered by default.

agent/conversation_loop.py

Lines changed: 1183 additions & 420 deletions
Large diffs are not rendered by default.

agent/error_classifier.py

Lines changed: 110 additions & 62 deletions
Large diffs are not rendered by default.

agent/retry_utils.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
rate-limited provider concurrently.
66
"""
77

8+
import calendar
9+
import email.utils
810
import random
911
import threading
1012
import time
@@ -47,11 +49,43 @@ def jittered_backoff(
4749
if exponent >= 63 or base_delay <= 0:
4850
delay = max_delay
4951
else:
50-
delay = min(base_delay * (2 ** exponent), max_delay)
52+
delay = min(base_delay * (2**exponent), max_delay)
5153

5254
# Seed from time + counter for decorrelation even with coarse clocks.
5355
seed = (time.time_ns() ^ (tick * 0x9E3779B9)) & 0xFFFFFFFF
5456
rng = random.Random(seed)
5557
jitter = rng.uniform(0, jitter_ratio * delay)
5658

5759
return delay + jitter
60+
61+
62+
def extract_retry_after_seconds(
63+
retry_after: str | None, now: float | None = None
64+
) -> float | None:
65+
"""Parse a Retry-After value into seconds from now.
66+
67+
Supports both integer seconds (RFC 7231 §7.1.3) and HTTP-date strings
68+
(RFC 7231 §7.1.1.2). Returns None for malformed/empty values.
69+
Caps the result at 300 seconds (5 minutes) so a far-future Retry-After
70+
header cannot stall the agent indefinitely.
71+
"""
72+
if not retry_after or not isinstance(retry_after, str):
73+
return None
74+
retry_after = retry_after.strip()
75+
if not retry_after:
76+
return None
77+
78+
# Try integer seconds first.
79+
try:
80+
return min(float(retry_after), 300.0)
81+
except (TypeError, ValueError):
82+
pass
83+
84+
# Try HTTP-date (e.g. "Wed, 21 Oct 2015 07:28:00 GMT").
85+
try:
86+
parsed = email.utils.parsedate_to_datetime(retry_after)
87+
retry_time = calendar.timegm(parsed.utctimetuple())
88+
now = now if now is not None else time.time()
89+
return max(0.0, min(retry_time - now, 300.0))
90+
except Exception:
91+
return None

agent/turn_retry_state.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class TurnRetryState:
5858
primary_recovery_attempted: bool = False
5959
has_retried_429: bool = False
6060

61+
# ── Fail-fast guard for non-retryable client errors ─────────────────
62+
fail_fast_attempted: bool = False
63+
6164
# ── Restart signals (read by the outer loop after the attempt) ───────
6265
restart_with_compressed_messages: bool = False
6366
restart_with_length_continuation: bool = False

0 commit comments

Comments
 (0)