Coverage for agentos/tests/test_retry.py: 0%

240 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-07 00:45 +0800

1"""Tests for agentos.core.retry — Retry, RetryConfig, delay functions.""" 

2 

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) 

17 

18 

19# ============================================================================ 

20# JitterStrategy 

21# ============================================================================ 

22 

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" 

29 

30 

31# ============================================================================ 

32# RetryConfig 

33# ============================================================================ 

34 

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 

45 

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) 

58 

59 

60# ============================================================================ 

61# _calc_jitter 

62# ============================================================================ 

63 

64class TestCalcJitter: 

65 def test_none(self): 

66 result = _calc_jitter(2.0, JitterStrategy.NONE) 

67 assert result == 2.0 

68 

69 def test_full(self): 

70 result = _calc_jitter(2.0, JitterStrategy.FULL) 

71 assert 0.0 <= result <= 2.0 

72 

73 def test_decorrelated(self): 

74 result = _calc_jitter(2.0, JitterStrategy.DECORRELATED) 

75 assert 0.0 <= result <= 2.0 

76 

77 def test_equal(self): 

78 result = _calc_jitter(2.0, JitterStrategy.EQUAL) 

79 assert 1.0 <= result <= 2.0 

80 

81 

82# ============================================================================ 

83# Delay functions 

84# ============================================================================ 

85 

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 

93 

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 

99 

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 

106 

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 

110 

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 

115 

116 def test_fixed_delay_cap(self): 

117 cfg = RetryConfig(base_delay=100, max_delay=50) 

118 assert fixed_delay(1, cfg) == 50.0 

119 

120 

121# ============================================================================ 

122# RetryResult 

123# ============================================================================ 

124 

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 

131 

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 

137 

138 

139# ============================================================================ 

140# Retry — execute 

141# ============================================================================ 

142 

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) 

148 

149 async def ok(): 

150 return "success" 

151 

152 result = await retry.execute(ok) 

153 assert result == "success" 

154 

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 = [] 

160 

161 async def flaky(): 

162 call_count.append(1) 

163 if len(call_count) < 2: 

164 raise ValueError("fail") 

165 return "ok" 

166 

167 result = await retry.execute(flaky) 

168 assert result == "ok" 

169 assert len(call_count) == 2 

170 

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) 

175 

176 async def always_fail(): 

177 raise ValueError("boom") 

178 

179 with pytest.raises(ValueError, match="boom"): 

180 await retry.execute(always_fail) 

181 

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) 

186 

187 async def fail_with_type(): 

188 raise TypeError("not retryable") 

189 

190 with pytest.raises(TypeError): 

191 await retry.execute(fail_with_type) 

192 

193 @pytest.mark.asyncio 

194 async def test_on_retry_callback(self): 

195 callbacks = [] 

196 

197 def on_retry(attempt, exc, delay): 

198 callbacks.append((attempt, delay)) 

199 

200 cfg = RetryConfig(max_retries=2, base_delay=0.01, on_retry=on_retry) 

201 retry = Retry(cfg) 

202 

203 async def flaky(): 

204 if len(callbacks) < 2: 

205 raise ValueError("x") 

206 return "done" 

207 

208 result = await retry.execute(flaky) 

209 assert result == "done" 

210 assert len(callbacks) == 2 

211 

212 @pytest.mark.asyncio 

213 async def test_args_kwargs_passed(self): 

214 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01)) 

215 

216 async def fn(a, b, c=None): 

217 return a + b + (c or 0) 

218 

219 result = await retry.execute(fn, 1, 2, c=3) 

220 assert result == 6 

221 

222 

223# ============================================================================ 

224# Retry — execute_with_result 

225# ============================================================================ 

226 

227class TestRetryExecuteWithResult: 

228 @pytest.mark.asyncio 

229 async def test_success_result(self): 

230 retry = Retry(RetryConfig(max_retries=3, base_delay=0.01)) 

231 

232 async def ok(): 

233 return 42 

234 

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 

240 

241 @pytest.mark.asyncio 

242 async def test_failure_result(self): 

243 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01)) 

244 

245 async def fail(): 

246 raise ValueError("x") 

247 

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) 

252 

253 @pytest.mark.asyncio 

254 async def test_retry_history(self): 

255 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01)) 

256 

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" 

264 

265 result = await retry.execute_with_result(flaky) 

266 assert result.success 

267 assert len(result.attempt_history) >= 1 

268 

269 

270# ============================================================================ 

271# Retry — with_retry decorator 

272# ============================================================================ 

273 

274class TestRetryDecorator: 

275 @pytest.mark.asyncio 

276 async def test_decorator_success(self): 

277 retry = Retry(RetryConfig(max_retries=2, base_delay=0.01)) 

278 

279 @retry.with_retry 

280 async def api(): 

281 return "ok" 

282 

283 assert await api() == "ok" 

284 

285 @pytest.mark.asyncio 

286 async def test_decorator_preserves_name(self): 

287 retry = Retry(RetryConfig(max_retries=1, base_delay=0.01)) 

288 

289 @retry.with_retry 

290 async def my_func(): 

291 return 1 

292 

293 assert my_func.__name__ == "my_func" 

294 

295 

296# ============================================================================ 

297# RetryPolicies 

298# ============================================================================ 

299 

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 

306 

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 

312 

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 

318 

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 

324 

325 def test_gentle(self): 

326 r = RetryPolicies.gentle() 

327 assert r.config.max_retries == 5 

328 assert r.config.jitter == JitterStrategy.FULL 

329 

330 

331# ============================================================================ 

332# SyncRetry 

333# ============================================================================ 

334 

335class TestSyncRetry: 

336 def test_sync_success(self): 

337 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01)) 

338 

339 def ok(): 

340 return "sync_ok" 

341 

342 result = retry.execute(ok) 

343 assert result == "sync_ok" 

344 

345 def test_sync_failure(self): 

346 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01)) 

347 

348 def fail(): 

349 raise ValueError("boom") 

350 

351 with pytest.raises(ValueError): 

352 retry.execute(fail) 

353 

354 def test_sync_decorator(self): 

355 retry = SyncRetry(RetryConfig(max_retries=2, base_delay=0.01)) 

356 

357 @retry.with_retry 

358 def fn(): 

359 return "wrapped" 

360 

361 assert fn() == "wrapped" 

362 assert fn.__name__ == "fn" 

363 

364 

365# ============================================================================ 

366# Edge cases 

367# ============================================================================ 

368 

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

373 

374 async def ok(): 

375 return "ok" 

376 

377 assert await retry.execute(ok) == "ok" 

378 

379 @pytest.mark.asyncio 

380 async def test_zero_max_retries_fail(self): 

381 retry = Retry(RetryConfig(max_retries=0, base_delay=0.01)) 

382 

383 async def fail(): 

384 raise ValueError("x") 

385 

386 with pytest.raises(ValueError): 

387 await retry.execute(fail)