Coverage for tests / unit / ai / test_retry.py: 99%
101 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Tests for AI retry decorator."""
3from __future__ import annotations
5from unittest.mock import patch
7import pytest
8from assertpy import assert_that
10from lintro.ai.exceptions import (
11 AIAuthenticationError,
12 AIProviderError,
13 AIRateLimitError,
14)
15from lintro.ai.retry import with_retry
18def test_retry_succeeds_on_first_attempt():
19 """Verify decorated function returns immediately on success without retrying."""
20 call_count = 0
22 @with_retry(max_retries=3)
23 def fn():
24 nonlocal call_count
25 call_count += 1
26 return "ok"
28 result = fn()
29 assert_that(result).is_equal_to("ok")
30 assert_that(call_count).is_equal_to(1)
33@patch("lintro.ai.retry.time.sleep")
34def test_retry_retries_on_provider_error(mock_sleep):
35 """Verify AIProviderError triggers retries until success."""
36 call_count = 0
38 @with_retry(max_retries=3, base_delay=1.0)
39 def fn():
40 nonlocal call_count
41 call_count += 1
42 if call_count < 3:
43 raise AIProviderError("server error")
44 return "ok"
46 result = fn()
47 assert_that(result).is_equal_to("ok")
48 assert_that(call_count).is_equal_to(3)
49 assert_that(mock_sleep.call_count).is_equal_to(2)
52@patch("lintro.ai.retry.time.sleep")
53def test_retry_retries_on_rate_limit_error(mock_sleep):
54 """Verify AIRateLimitError triggers retries until success."""
55 call_count = 0
57 @with_retry(max_retries=2, base_delay=1.0)
58 def fn():
59 nonlocal call_count
60 call_count += 1
61 if call_count < 2:
62 raise AIRateLimitError("rate limited")
63 return "ok"
65 result = fn()
66 assert_that(result).is_equal_to("ok")
67 assert_that(call_count).is_equal_to(2)
68 mock_sleep.assert_called_once()
71def test_retry_does_not_retry_on_authentication_error():
72 """Verify AIAuthenticationError is raised immediately without retrying."""
73 call_count = 0
75 @with_retry(max_retries=3)
76 def fn():
77 nonlocal call_count
78 call_count += 1
79 raise AIAuthenticationError("bad key")
81 with pytest.raises(AIAuthenticationError):
82 fn()
83 assert_that(call_count).is_equal_to(1)
86@patch("lintro.ai.retry.time.sleep")
87def test_retry_raises_after_max_retries_exhausted(mock_sleep):
88 """Verify the original error is raised after all retry attempts are exhausted."""
90 @with_retry(max_retries=2, base_delay=0.1)
91 def fn():
92 raise AIProviderError("always fails")
94 with pytest.raises(AIProviderError, match="always fails"):
95 fn()
96 assert_that(mock_sleep.call_count).is_equal_to(2)
99@patch("lintro.ai.retry.random.uniform", return_value=1.0)
100@patch("lintro.ai.retry.time.sleep")
101def test_retry_exponential_backoff_delays(mock_sleep, _mock_uniform):
102 """Verify retry delays follow exponential backoff progression."""
103 call_count = 0
105 @with_retry(max_retries=3, base_delay=1.0, backoff_factor=2.0)
106 def fn():
107 nonlocal call_count
108 call_count += 1
109 if call_count <= 3:
110 raise AIProviderError("fail")
111 return "ok"
113 result = fn()
114 assert_that(result).is_equal_to("ok")
116 delays = [call.args[0] for call in mock_sleep.call_args_list]
117 assert_that(delays).is_equal_to([1.0, 2.0, 4.0])
120@patch("lintro.ai.retry.random.uniform", return_value=1.0)
121@patch("lintro.ai.retry.time.sleep")
122def test_retry_max_delay_cap(mock_sleep, _mock_uniform):
123 """Verify retry delays are capped at the configured max_delay value."""
124 call_count = 0
126 @with_retry(
127 max_retries=5,
128 base_delay=10.0,
129 backoff_factor=3.0,
130 max_delay=25.0,
131 )
132 def fn():
133 nonlocal call_count
134 call_count += 1
135 if call_count <= 5:
136 raise AIProviderError("fail")
137 return "ok"
139 fn()
140 delays = [call.args[0] for call in mock_sleep.call_args_list]
141 assert_that(delays).is_length(5)
142 assert_that(delays).is_equal_to([10.0, 25.0, 25.0, 25.0, 25.0])
145def test_retry_does_not_retry_non_ai_exceptions():
146 """Verify non-AI exceptions propagate immediately without retrying."""
147 call_count = 0
149 @with_retry(max_retries=3)
150 def fn():
151 nonlocal call_count
152 call_count += 1
153 raise ValueError("not an AI error")
155 with pytest.raises(ValueError, match="not an AI error"):
156 fn()
157 assert_that(call_count).is_equal_to(1)
160def test_retry_preserves_function_metadata():
161 """Verify the retry decorator preserves the wrapped function name and docstring."""
163 @with_retry(max_retries=1)
164 def my_function():
165 """My docstring."""
166 return 42
168 assert_that(my_function.__name__).is_equal_to("my_function")
169 assert_that(my_function.__doc__).is_equal_to("My docstring.")