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

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

2 

3import pytest 

4 

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# JitterStrategy 

20# ============================================================================ 

21 

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" 

28 

29 

30# ============================================================================ 

31# RetryConfig 

32# ============================================================================ 

33 

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 

44 

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) 

57 

58 

59# ============================================================================ 

60# _calc_jitter 

61# ============================================================================ 

62 

63class TestCalcJitter: 

64 def test_none(self): 

65 result = _calc_jitter(2.0, JitterStrategy.NONE) 

66 assert result == 2.0 

67 

68 def test_full(self): 

69 result = _calc_jitter(2.0, JitterStrategy.FULL) 

70 assert 0.0 <= result <= 2.0 

71 

72 def test_decorrelated(self): 

73 result = _calc_jitter(2.0, JitterStrategy.DECORRELATED) 

74 assert 0.0 <= result <= 2.0 

75 

76 def test_equal(self): 

77 result = _calc_jitter(2.0, JitterStrategy.EQUAL) 

78 assert 1.0 <= result <= 2.0 

79 

80 

81# ============================================================================ 

82# Delay functions 

83# ============================================================================ 

84 

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 

92 

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 

98 

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 

105 

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 

109 

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 

114 

115 def test_fixed_delay_cap(self): 

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

117 assert fixed_delay(1, cfg) == 50.0 

118 

119 

120# ============================================================================ 

121# RetryResult 

122# ============================================================================ 

123 

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 

130 

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 

136 

137 

138# ============================================================================ 

139# Retry — execute 

140# ============================================================================ 

141 

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) 

147 

148 async def ok(): 

149 return "success" 

150 

151 result = await retry.execute(ok) 

152 assert result == "success" 

153 

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

159 

160 async def flaky(): 

161 call_count.append(1) 

162 if len(call_count) < 2: 

163 raise ValueError("fail") 

164 return "ok" 

165 

166 result = await retry.execute(flaky) 

167 assert result == "ok" 

168 assert len(call_count) == 2 

169 

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) 

174 

175 async def always_fail(): 

176 raise ValueError("boom") 

177 

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

179 await retry.execute(always_fail) 

180 

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) 

185 

186 async def fail_with_type(): 

187 raise TypeError("not retryable") 

188 

189 with pytest.raises(TypeError): 

190 await retry.execute(fail_with_type) 

191 

192 @pytest.mark.asyncio 

193 async def test_on_retry_callback(self): 

194 callbacks = [] 

195 

196 def on_retry(attempt, exc, delay): 

197 callbacks.append((attempt, delay)) 

198 

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

200 retry = Retry(cfg) 

201 

202 async def flaky(): 

203 if len(callbacks) < 2: 

204 raise ValueError("x") 

205 return "done" 

206 

207 result = await retry.execute(flaky) 

208 assert result == "done" 

209 assert len(callbacks) == 2 

210 

211 @pytest.mark.asyncio 

212 async def test_args_kwargs_passed(self): 

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

214 

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

216 return a + b + (c or 0) 

217 

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

219 assert result == 6 

220 

221 

222# ============================================================================ 

223# Retry — execute_with_result 

224# ============================================================================ 

225 

226class TestRetryExecuteWithResult: 

227 @pytest.mark.asyncio 

228 async def test_success_result(self): 

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

230 

231 async def ok(): 

232 return 42 

233 

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 

239 

240 @pytest.mark.asyncio 

241 async def test_failure_result(self): 

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

243 

244 async def fail(): 

245 raise ValueError("x") 

246 

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) 

251 

252 @pytest.mark.asyncio 

253 async def test_retry_history(self): 

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

255 

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" 

263 

264 result = await retry.execute_with_result(flaky) 

265 assert result.success 

266 assert len(result.attempt_history) >= 1 

267 

268 

269# ============================================================================ 

270# Retry — with_retry decorator 

271# ============================================================================ 

272 

273class TestRetryDecorator: 

274 @pytest.mark.asyncio 

275 async def test_decorator_success(self): 

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

277 

278 @retry.with_retry 

279 async def api(): 

280 return "ok" 

281 

282 assert await api() == "ok" 

283 

284 @pytest.mark.asyncio 

285 async def test_decorator_preserves_name(self): 

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

287 

288 @retry.with_retry 

289 async def my_func(): 

290 return 1 

291 

292 assert my_func.__name__ == "my_func" 

293 

294 

295# ============================================================================ 

296# RetryPolicies 

297# ============================================================================ 

298 

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 

305 

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 

311 

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 

317 

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 

323 

324 def test_gentle(self): 

325 r = RetryPolicies.gentle() 

326 assert r.config.max_retries == 5 

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

328 

329 

330# ============================================================================ 

331# SyncRetry 

332# ============================================================================ 

333 

334class TestSyncRetry: 

335 def test_sync_success(self): 

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

337 

338 def ok(): 

339 return "sync_ok" 

340 

341 result = retry.execute(ok) 

342 assert result == "sync_ok" 

343 

344 def test_sync_failure(self): 

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

346 

347 def fail(): 

348 raise ValueError("boom") 

349 

350 with pytest.raises(ValueError): 

351 retry.execute(fail) 

352 

353 def test_sync_decorator(self): 

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

355 

356 @retry.with_retry 

357 def fn(): 

358 return "wrapped" 

359 

360 assert fn() == "wrapped" 

361 assert fn.__name__ == "fn" 

362 

363 

364# ============================================================================ 

365# Edge cases 

366# ============================================================================ 

367 

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

372 

373 async def ok(): 

374 return "ok" 

375 

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

377 

378 @pytest.mark.asyncio 

379 async def test_zero_max_retries_fail(self): 

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

381 

382 async def fail(): 

383 raise ValueError("x") 

384 

385 with pytest.raises(ValueError): 

386 await retry.execute(fail)