Coverage for agentos/tests/test_retry.py: 99%
240 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 23:40 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 23:40 +0800
1"""Tests for agentos.core.retry — Retry, RetryConfig, delay functions."""
3import asyncio
4import pytest
5from agentos.core.retry import (
6 JitterStrategy,
7 Retry,
8 RetryConfig,
9 RetryPolicies,
10 RetryResult,
11 SyncRetry,
12 _calc_jitter,
13 exponential_delay,
14 fibonacci_delay,
15 fixed_delay,
16)
19# ============================================================================
20# JitterStrategy
21# ============================================================================
23class TestJitterStrategy:
24 def test_enum_values(self):
25 assert JitterStrategy.NONE == "none"
26 assert JitterStrategy.FULL == "full"
27 assert JitterStrategy.DECORRELATED == "decorrelated"
28 assert JitterStrategy.EQUAL == "equal"
31# ============================================================================
32# RetryConfig
33# ============================================================================
35class TestRetryConfig:
36 def test_defaults(self):
37 cfg = RetryConfig()
38 assert cfg.max_retries == 3
39 assert cfg.base_delay == 1.0
40 assert cfg.max_delay == 60.0
41 assert cfg.multiplier == 2.0
42 assert cfg.jitter == JitterStrategy.DECORRELATED
43 assert cfg.retryable_exceptions == (Exception,)
44 assert cfg.on_retry is None
46 def test_custom(self):
47 cfg = RetryConfig(
48 max_retries=5,
49 base_delay=0.5,
50 max_delay=10.0,
51 multiplier=3.0,
52 jitter=JitterStrategy.FULL,
53 retryable_exceptions=(ValueError, TypeError),
54 )
55 assert cfg.max_retries == 5
56 assert cfg.multiplier == 3.0
57 assert cfg.retryable_exceptions == (ValueError, TypeError)
60# ============================================================================
61# _calc_jitter
62# ============================================================================
64class TestCalcJitter:
65 def test_none(self):
66 result = _calc_jitter(2.0, JitterStrategy.NONE)
67 assert result == 2.0
69 def test_full(self):
70 result = _calc_jitter(2.0, JitterStrategy.FULL)
71 assert 0.0 <= result <= 2.0
73 def test_decorrelated(self):
74 result = _calc_jitter(2.0, JitterStrategy.DECORRELATED)
75 assert 0.0 <= result <= 2.0
77 def test_equal(self):
78 result = _calc_jitter(2.0, JitterStrategy.EQUAL)
79 assert 1.0 <= result <= 2.0
82# ============================================================================
83# Delay functions
84# ============================================================================
86class TestDelayFunctions:
87 def test_exponential_delay_basic(self):
88 cfg = RetryConfig(base_delay=1.0, multiplier=2.0, max_delay=60, jitter=JitterStrategy.NONE)
89 d1 = exponential_delay(1, cfg)
90 d2 = exponential_delay(2, cfg)
91 assert d1 == 1.0
92 assert d2 == 2.0
94 def test_exponential_delay_cap(self):
95 cfg = RetryConfig(base_delay=50, max_delay=60, jitter=JitterStrategy.NONE)
96 assert exponential_delay(1, cfg) == 50.0
97 # attempt 2: 50*2 = 100, capped to 60
98 assert exponential_delay(2, cfg) == 60.0
100 def test_fibonacci_delay(self):
101 cfg = RetryConfig(base_delay=1.0, max_delay=60, jitter=JitterStrategy.NONE)
102 # fib: 1,1,2,3,5,8...
103 assert fibonacci_delay(1, cfg) == 1.0 # fib(1)=1 * 1.0
104 assert fibonacci_delay(2, cfg) == 1.0 # fib(2)=1 * 1.0
105 assert fibonacci_delay(3, cfg) == 2.0 # fib(3)=2 * 1.0
107 def test_fibonacci_delay_cap(self):
108 cfg = RetryConfig(base_delay=100, max_delay=50, jitter=JitterStrategy.NONE)
109 assert fibonacci_delay(1, cfg) == 50.0
111 def test_fixed_delay(self):
112 cfg = RetryConfig(base_delay=2.0, max_delay=60)
113 assert fixed_delay(1, cfg) == 2.0
114 assert fixed_delay(10, cfg) == 2.0
116 def test_fixed_delay_cap(self):
117 cfg = RetryConfig(base_delay=100, max_delay=50)
118 assert fixed_delay(1, cfg) == 50.0
121# ============================================================================
122# RetryResult
123# ============================================================================
125class TestRetryResult:
126 def test_success(self):
127 r = RetryResult(attempts=1, success=True, total_delay=0.5)
128 assert r.success is True
129 assert r.last_exception is None
130 assert r.total_delay == 0.5
132 def test_failure(self):
133 exc = ValueError("x")
134 r = RetryResult(attempts=3, success=False, last_exception=exc, total_delay=2.0)
135 assert r.success is False
136 assert r.last_exception is exc
139# ============================================================================
140# Retry — execute
141# ============================================================================
143class TestRetryExecute:
144 @pytest.mark.asyncio
145 async def test_first_attempt_success(self):
146 cfg = RetryConfig(max_retries=3, base_delay=0.01)
147 retry = Retry(cfg)
149 async def ok():
150 return "success"
152 result = await retry.execute(ok)
153 assert result == "success"
155 @pytest.mark.asyncio
156 async def test_retry_succeeds_on_second_attempt(self):
157 cfg = RetryConfig(max_retries=3, base_delay=0.01)
158 retry = Retry(cfg)
159 call_count = []
161 async def flaky():
162 call_count.append(1)
163 if len(call_count) < 2:
164 raise ValueError("fail")
165 return "ok"
167 result = await retry.execute(flaky)
168 assert result == "ok"
169 assert len(call_count) == 2
171 @pytest.mark.asyncio
172 async def test_exhausts_retries(self):
173 cfg = RetryConfig(max_retries=2, base_delay=0.01)
174 retry = Retry(cfg)
176 async def always_fail():
177 raise ValueError("boom")
179 with pytest.raises(ValueError, match="boom"):
180 await retry.execute(always_fail)
182 @pytest.mark.asyncio
183 async def test_non_retryable_exception(self):
184 cfg = RetryConfig(max_retries=3, base_delay=0.01, retryable_exceptions=(ValueError,))
185 retry = Retry(cfg)
187 async def fail_with_type():
188 raise TypeError("not retryable")
190 with pytest.raises(TypeError):
191 await retry.execute(fail_with_type)
193 @pytest.mark.asyncio
194 async def test_on_retry_callback(self):
195 callbacks = []
197 def on_retry(attempt, exc, delay):
198 callbacks.append((attempt, delay))
200 cfg = RetryConfig(max_retries=2, base_delay=0.01, on_retry=on_retry)
201 retry = Retry(cfg)
203 async def flaky():
204 if len(callbacks) < 2:
205 raise ValueError("x")
206 return "done"
208 result = await retry.execute(flaky)
209 assert result == "done"
210 assert len(callbacks) == 2
212 @pytest.mark.asyncio
213 async def test_args_kwargs_passed(self):
214 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01))
216 async def fn(a, b, c=None):
217 return a + b + (c or 0)
219 result = await retry.execute(fn, 1, 2, c=3)
220 assert result == 6
223# ============================================================================
224# Retry — execute_with_result
225# ============================================================================
227class TestRetryExecuteWithResult:
228 @pytest.mark.asyncio
229 async def test_success_result(self):
230 retry = Retry(RetryConfig(max_retries=3, base_delay=0.01))
232 async def ok():
233 return 42
235 result = await retry.execute_with_result(ok)
236 assert isinstance(result, RetryResult)
237 assert result.success is True
238 assert result.attempts == 1
239 assert len(result.attempt_history) == 1
241 @pytest.mark.asyncio
242 async def test_failure_result(self):
243 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01))
245 async def fail():
246 raise ValueError("x")
248 result = await retry.execute_with_result(fail)
249 assert result.success is False
250 assert result.attempts == 3 # initial + 2 retries
251 assert isinstance(result.last_exception, ValueError)
253 @pytest.mark.asyncio
254 async def test_retry_history(self):
255 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01))
257 async def flaky():
258 if not hasattr(flaky, "count"):
259 flaky.count = 0
260 flaky.count += 1
261 if flaky.count < 2:
262 raise ValueError("fail")
263 return "ok"
265 result = await retry.execute_with_result(flaky)
266 assert result.success
267 assert len(result.attempt_history) >= 1
270# ============================================================================
271# Retry — with_retry decorator
272# ============================================================================
274class TestRetryDecorator:
275 @pytest.mark.asyncio
276 async def test_decorator_success(self):
277 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01))
279 @retry.with_retry
280 async def api():
281 return "ok"
283 assert await api() == "ok"
285 @pytest.mark.asyncio
286 async def test_decorator_preserves_name(self):
287 retry = Retry(RetryConfig(max_retries=1, base_delay=0.01))
289 @retry.with_retry
290 async def my_func():
291 return 1
293 assert my_func.__name__ == "my_func"
296# ============================================================================
297# RetryPolicies
298# ============================================================================
300class TestRetryPolicies:
301 def test_fast(self):
302 r = RetryPolicies.fast()
303 assert r.config.max_retries == 3
304 assert r.config.base_delay == 0.1
305 assert r.config.max_delay == 1.0
307 def test_standard(self):
308 r = RetryPolicies.standard()
309 assert r.config.max_retries == 5
310 assert r.config.base_delay == 0.5
311 assert r.config.max_delay == 30.0
313 def test_persistent(self):
314 r = RetryPolicies.persistent()
315 assert r.config.max_retries == 10
316 assert r.config.base_delay == 1.0
317 assert r.config.max_delay == 120.0
319 def test_immediate(self):
320 r = RetryPolicies.immediate()
321 assert r.config.max_retries == 2
322 assert r.config.base_delay == 0.0
323 assert r.config.jitter == JitterStrategy.NONE
325 def test_gentle(self):
326 r = RetryPolicies.gentle()
327 assert r.config.max_retries == 5
328 assert r.config.jitter == JitterStrategy.FULL
331# ============================================================================
332# SyncRetry
333# ============================================================================
335class TestSyncRetry:
336 def test_sync_success(self):
337 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01))
339 def ok():
340 return "sync_ok"
342 result = retry.execute(ok)
343 assert result == "sync_ok"
345 def test_sync_failure(self):
346 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01))
348 def fail():
349 raise ValueError("boom")
351 with pytest.raises(ValueError):
352 retry.execute(fail)
354 def test_sync_decorator(self):
355 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01))
357 @retry.with_retry
358 def fn():
359 return "wrapped"
361 assert fn() == "wrapped"
362 assert fn.__name__ == "fn"
365# ============================================================================
366# Edge cases
367# ============================================================================
369class TestRetryEdgeCases:
370 @pytest.mark.asyncio
371 async def test_zero_max_retries_success(self):
372 retry = Retry(RetryConfig(max_retries=0, base_delay=0.01))
374 async def ok():
375 return "ok"
377 assert await retry.execute(ok) == "ok"
379 @pytest.mark.asyncio
380 async def test_zero_max_retries_fail(self):
381 retry = Retry(RetryConfig(max_retries=0, base_delay=0.01))
383 async def fail():
384 raise ValueError("x")
386 with pytest.raises(ValueError):
387 await retry.execute(fail)