Coverage for merco/core/recovery/wait.py: 100%
36 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
1"""WaitRecovery — delays before retry on errors.
3Differentiated wait:
4- 429/5xx/network/timeout: exponential backoff starting at `delay`, capped at `max_delay`
5- 413/context-length: returns False (let ContextCompressRecovery handle)
6- 401/403/404: one short 1.0s retry, then gives up
7- Other 4xx/unknown: backoff starting at delay*0.66 (slightly shorter)
8"""
9from __future__ import annotations
11import logging
12from merco.core.pipeline import Recovery, RecoveryContext
14logger = logging.getLogger("merco.pipeline")
16# Status codes that are deterministic (wrong config) — fast retry once, then stop.
17SHORT_RETRY_STATUSES = frozenset({401, 403, 404})
20class WaitRecovery(Recovery):
21 """Wait before retry; differentiated policy by error type."""
23 name = "wait"
25 def __init__(self, delay: float = 3.0, max_delay: float = 30.0,
26 short_delay: float = 1.0):
27 self.delay = delay
28 self.max_delay = max_delay
29 self.short_delay = short_delay
31 async def attempt(self, ctx: RecoveryContext) -> bool:
32 status = getattr(ctx.error, "status_code", None)
33 body = str(ctx.error).lower()
34 name = type(ctx.error).__name__.lower()
36 # 413/too-long → compression handles it
37 if status == 413 or "context length" in body or "maximum context" in body \
38 or "prompt too long" in body \
39 or ("too long" in body and "context" in body):
40 return False
42 # Deterministic errors: one short retry only
43 if status in SHORT_RETRY_STATUSES:
44 if ctx.attempt_count >= 1:
45 return False
46 delay = self.short_delay
47 logger.info("→ 确定性错误 (status=%s),快速重试一次(%.1fs)",
48 status, delay)
49 ctx.extra_wait = max(ctx.extra_wait, delay)
50 return True
52 # Network/timeout detection
53 is_network = (
54 "connection" in name
55 or "timeout" in name
56 or "connect" in body
57 or "timeout" in body
58 or "timed out" in body
59 or "network" in body
60 )
62 # Pick base delay
63 is_5xx = isinstance(status, int) and 500 <= status <= 599
64 is_429 = status == 429 or "rate limit" in body or "too many requests" in body
65 if is_5xx or is_429 or is_network:
66 base = self.delay
67 elif isinstance(status, int) and 400 <= status < 500:
68 base = self.delay * 0.66
69 else:
70 base = self.delay * 0.66
72 delay = min(base * (2 ** ctx.attempt_count), self.max_delay)
73 logger.info("→ 等待 %.1fs 后重试 LLM(attempt=%d, status=%s)",
74 delay, ctx.attempt_count + 1, status)
75 ctx.extra_wait = max(ctx.extra_wait, delay)
76 return True