Coverage for agentos/tests/test_retry.py: 0%
239 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:28 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:28 +0800
1"""Tests for agentos.core.retry — Retry, RetryConfig, delay functions."""
3import 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)
18# ============================================================================
19# JitterStrategy
20# ============================================================================
22class TestJitterStrategy:
23 def test_enum_values(self):
24 assert JitterStrategy.NONE == "none"
25 assert JitterStrategy.FULL == "full"
26 assert JitterStrategy.DECORRELATED == "decorrelated"
27 assert JitterStrategy.EQUAL == "equal"
30# ============================================================================
31# RetryConfig
32# ============================================================================
34class TestRetryConfig:
35 def test_defaults(self):
36 cfg = RetryConfig()
37 assert cfg.max_retries == 3
38 assert cfg.base_delay == 1.0
39 assert cfg.max_delay == 60.0
40 assert cfg.multiplier == 2.0
41 assert cfg.jitter == JitterStrategy.DECORRELATED
42 assert cfg.retryable_exceptions == (Exception,)
43 assert cfg.on_retry is None
45 def test_custom(self):
46 cfg = RetryConfig(
47 max_retries=5,
48 base_delay=0.5,
49 max_delay=10.0,
50 multiplier=3.0,
51 jitter=JitterStrategy.FULL,
52 retryable_exceptions=(ValueError, TypeError),
53 )
54 assert cfg.max_retries == 5
55 assert cfg.multiplier == 3.0
56 assert cfg.retryable_exceptions == (ValueError, TypeError)
59# ============================================================================
60# _calc_jitter
61# ============================================================================
63class TestCalcJitter:
64 def test_none(self):
65 result = _calc_jitter(2.0, JitterStrategy.NONE)
66 assert result == 2.0
68 def test_full(self):
69 result = _calc_jitter(2.0, JitterStrategy.FULL)
70 assert 0.0 <= result <= 2.0
72 def test_decorrelated(self):
73 result = _calc_jitter(2.0, JitterStrategy.DECORRELATED)
74 assert 0.0 <= result <= 2.0
76 def test_equal(self):
77 result = _calc_jitter(2.0, JitterStrategy.EQUAL)
78 assert 1.0 <= result <= 2.0
81# ============================================================================
82# Delay functions
83# ============================================================================
85class TestDelayFunctions:
86 def test_exponential_delay_basic(self):
87 cfg = RetryConfig(base_delay=1.0, multiplier=2.0, max_delay=60, jitter=JitterStrategy.NONE)
88 d1 = exponential_delay(1, cfg)
89 d2 = exponential_delay(2, cfg)
90 assert d1 == 1.0
91 assert d2 == 2.0
93 def test_exponential_delay_cap(self):
94 cfg = RetryConfig(base_delay=50, max_delay=60, jitter=JitterStrategy.NONE)
95 assert exponential_delay(1, cfg) == 50.0
96 # attempt 2: 50*2 = 100, capped to 60
97 assert exponential_delay(2, cfg) == 60.0
99 def test_fibonacci_delay(self):
100 cfg = RetryConfig(base_delay=1.0, max_delay=60, jitter=JitterStrategy.NONE)
101 # fib: 1,1,2,3,5,8...
102 assert fibonacci_delay(1, cfg) == 1.0 # fib(1)=1 * 1.0
103 assert fibonacci_delay(2, cfg) == 1.0 # fib(2)=1 * 1.0
104 assert fibonacci_delay(3, cfg) == 2.0 # fib(3)=2 * 1.0
106 def test_fibonacci_delay_cap(self):
107 cfg = RetryConfig(base_delay=100, max_delay=50, jitter=JitterStrategy.NONE)
108 assert fibonacci_delay(1, cfg) == 50.0
110 def test_fixed_delay(self):
111 cfg = RetryConfig(base_delay=2.0, max_delay=60)
112 assert fixed_delay(1, cfg) == 2.0
113 assert fixed_delay(10, cfg) == 2.0
115 def test_fixed_delay_cap(self):
116 cfg = RetryConfig(base_delay=100, max_delay=50)
117 assert fixed_delay(1, cfg) == 50.0
120# ============================================================================
121# RetryResult
122# ============================================================================
124class TestRetryResult:
125 def test_success(self):
126 r = RetryResult(attempts=1, success=True, total_delay=0.5)
127 assert r.success is True
128 assert r.last_exception is None
129 assert r.total_delay == 0.5
131 def test_failure(self):
132 exc = ValueError("x")
133 r = RetryResult(attempts=3, success=False, last_exception=exc, total_delay=2.0)
134 assert r.success is False
135 assert r.last_exception is exc
138# ============================================================================
139# Retry — execute
140# ============================================================================
142class TestRetryExecute:
143 @pytest.mark.asyncio
144 async def test_first_attempt_success(self):
145 cfg = RetryConfig(max_retries=3, base_delay=0.01)
146 retry = Retry(cfg)
148 async def ok():
149 return "success"
151 result = await retry.execute(ok)
152 assert result == "success"
154 @pytest.mark.asyncio
155 async def test_retry_succeeds_on_second_attempt(self):
156 cfg = RetryConfig(max_retries=3, base_delay=0.01)
157 retry = Retry(cfg)
158 call_count = []
160 async def flaky():
161 call_count.append(1)
162 if len(call_count) < 2:
163 raise ValueError("fail")
164 return "ok"
166 result = await retry.execute(flaky)
167 assert result == "ok"
168 assert len(call_count) == 2
170 @pytest.mark.asyncio
171 async def test_exhausts_retries(self):
172 cfg = RetryConfig(max_retries=2, base_delay=0.01)
173 retry = Retry(cfg)
175 async def always_fail():
176 raise ValueError("boom")
178 with pytest.raises(ValueError, match="boom"):
179 await retry.execute(always_fail)
181 @pytest.mark.asyncio
182 async def test_non_retryable_exception(self):
183 cfg = RetryConfig(max_retries=3, base_delay=0.01, retryable_exceptions=(ValueError,))
184 retry = Retry(cfg)
186 async def fail_with_type():
187 raise TypeError("not retryable")
189 with pytest.raises(TypeError):
190 await retry.execute(fail_with_type)
192 @pytest.mark.asyncio
193 async def test_on_retry_callback(self):
194 callbacks = []
196 def on_retry(attempt, exc, delay):
197 callbacks.append((attempt, delay))
199 cfg = RetryConfig(max_retries=2, base_delay=0.01, on_retry=on_retry)
200 retry = Retry(cfg)
202 async def flaky():
203 if len(callbacks) < 2:
204 raise ValueError("x")
205 return "done"
207 result = await retry.execute(flaky)
208 assert result == "done"
209 assert len(callbacks) == 2
211 @pytest.mark.asyncio
212 async def test_args_kwargs_passed(self):
213 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01))
215 async def fn(a, b, c=None):
216 return a + b + (c or 0)
218 result = await retry.execute(fn, 1, 2, c=3)
219 assert result == 6
222# ============================================================================
223# Retry — execute_with_result
224# ============================================================================
226class TestRetryExecuteWithResult:
227 @pytest.mark.asyncio
228 async def test_success_result(self):
229 retry = Retry(RetryConfig(max_retries=3, base_delay=0.01))
231 async def ok():
232 return 42
234 result = await retry.execute_with_result(ok)
235 assert isinstance(result, RetryResult)
236 assert result.success is True
237 assert result.attempts == 1
238 assert len(result.attempt_history) == 1
240 @pytest.mark.asyncio
241 async def test_failure_result(self):
242 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01))
244 async def fail():
245 raise ValueError("x")
247 result = await retry.execute_with_result(fail)
248 assert result.success is False
249 assert result.attempts == 3 # initial + 2 retries
250 assert isinstance(result.last_exception, ValueError)
252 @pytest.mark.asyncio
253 async def test_retry_history(self):
254 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01))
256 async def flaky():
257 if not hasattr(flaky, "count"):
258 flaky.count = 0
259 flaky.count += 1
260 if flaky.count < 2:
261 raise ValueError("fail")
262 return "ok"
264 result = await retry.execute_with_result(flaky)
265 assert result.success
266 assert len(result.attempt_history) >= 1
269# ============================================================================
270# Retry — with_retry decorator
271# ============================================================================
273class TestRetryDecorator:
274 @pytest.mark.asyncio
275 async def test_decorator_success(self):
276 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01))
278 @retry.with_retry
279 async def api():
280 return "ok"
282 assert await api() == "ok"
284 @pytest.mark.asyncio
285 async def test_decorator_preserves_name(self):
286 retry = Retry(RetryConfig(max_retries=1, base_delay=0.01))
288 @retry.with_retry
289 async def my_func():
290 return 1
292 assert my_func.__name__ == "my_func"
295# ============================================================================
296# RetryPolicies
297# ============================================================================
299class TestRetryPolicies:
300 def test_fast(self):
301 r = RetryPolicies.fast()
302 assert r.config.max_retries == 3
303 assert r.config.base_delay == 0.1
304 assert r.config.max_delay == 1.0
306 def test_standard(self):
307 r = RetryPolicies.standard()
308 assert r.config.max_retries == 5
309 assert r.config.base_delay == 0.5
310 assert r.config.max_delay == 30.0
312 def test_persistent(self):
313 r = RetryPolicies.persistent()
314 assert r.config.max_retries == 10
315 assert r.config.base_delay == 1.0
316 assert r.config.max_delay == 120.0
318 def test_immediate(self):
319 r = RetryPolicies.immediate()
320 assert r.config.max_retries == 2
321 assert r.config.base_delay == 0.0
322 assert r.config.jitter == JitterStrategy.NONE
324 def test_gentle(self):
325 r = RetryPolicies.gentle()
326 assert r.config.max_retries == 5
327 assert r.config.jitter == JitterStrategy.FULL
330# ============================================================================
331# SyncRetry
332# ============================================================================
334class TestSyncRetry:
335 def test_sync_success(self):
336 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01))
338 def ok():
339 return "sync_ok"
341 result = retry.execute(ok)
342 assert result == "sync_ok"
344 def test_sync_failure(self):
345 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01))
347 def fail():
348 raise ValueError("boom")
350 with pytest.raises(ValueError):
351 retry.execute(fail)
353 def test_sync_decorator(self):
354 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01))
356 @retry.with_retry
357 def fn():
358 return "wrapped"
360 assert fn() == "wrapped"
361 assert fn.__name__ == "fn"
364# ============================================================================
365# Edge cases
366# ============================================================================
368class TestRetryEdgeCases:
369 @pytest.mark.asyncio
370 async def test_zero_max_retries_success(self):
371 retry = Retry(RetryConfig(max_retries=0, base_delay=0.01))
373 async def ok():
374 return "ok"
376 assert await retry.execute(ok) == "ok"
378 @pytest.mark.asyncio
379 async def test_zero_max_retries_fail(self):
380 retry = Retry(RetryConfig(max_retries=0, base_delay=0.01))
382 async def fail():
383 raise ValueError("x")
385 with pytest.raises(ValueError):
386 await retry.execute(fail)