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

1"""WaitRecovery — delays before retry on errors. 

2 

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 

10 

11import logging 

12from merco.core.pipeline import Recovery, RecoveryContext 

13 

14logger = logging.getLogger("merco.pipeline") 

15 

16# Status codes that are deterministic (wrong config) — fast retry once, then stop. 

17SHORT_RETRY_STATUSES = frozenset({401, 403, 404}) 

18 

19 

20class WaitRecovery(Recovery): 

21 """Wait before retry; differentiated policy by error type.""" 

22 

23 name = "wait" 

24 

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 

30 

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() 

35 

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 

41 

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 

51 

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 ) 

61 

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 

71 

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