|
1 | 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
2 | 2 | # SPDX-License-Identifier: Apache-2.0 |
3 | 3 |
|
| 4 | +from asyncio import gather, sleep |
| 5 | + |
4 | 6 | import pytest |
5 | 7 | from smithy_core.exceptions import CallError, RetryError |
6 | | -from smithy_core.retries import StandardRetryQuota, StandardRetryStrategy |
7 | | - |
8 | | - |
9 | | -def test_standard_retry_eventually_succeeds() -> None: |
10 | | - retry_quota = StandardRetryQuota() |
11 | | - strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) |
12 | | - error = CallError(is_retry_safe=True) |
13 | | - |
| 8 | +from smithy_core.interfaces import retries as retries_interface |
| 9 | +from smithy_core.retries import ( |
| 10 | + ExponentialBackoffJitterType, |
| 11 | + ExponentialRetryBackoffStrategy, |
| 12 | + StandardRetryQuota, |
| 13 | + StandardRetryStrategy, |
| 14 | +) |
| 15 | + |
| 16 | + |
| 17 | +async def retry_operation( |
| 18 | + strategy: retries_interface.RetryStrategy, |
| 19 | + status_codes: list[int], |
| 20 | +) -> tuple[str, int]: |
14 | 21 | token = strategy.acquire_initial_retry_token() |
15 | | - assert token.retry_count == 0 |
16 | | - assert retry_quota.available_capacity == 500 |
| 22 | + responses = iter(status_codes) |
17 | 23 |
|
18 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
19 | | - assert token.retry_count == 1 |
20 | | - assert retry_quota.available_capacity == 495 |
| 24 | + while True: |
| 25 | + if token.retry_delay: |
| 26 | + await sleep(token.retry_delay) |
21 | 27 |
|
22 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
23 | | - assert token.retry_count == 2 |
24 | | - assert retry_quota.available_capacity == 490 |
| 28 | + status_code = next(responses) |
| 29 | + attempt = token.retry_count + 1 |
25 | 30 |
|
26 | | - strategy.record_success(token=token) |
27 | | - assert retry_quota.available_capacity == 495 |
| 31 | + if status_code == 200: |
| 32 | + strategy.record_success(token=token) |
| 33 | + return "success", attempt |
28 | 34 |
|
| 35 | + error = CallError( |
| 36 | + fault="server" if status_code >= 500 else "client", |
| 37 | + message=f"HTTP {status_code}", |
| 38 | + is_retry_safe=status_code >= 500, |
| 39 | + ) |
29 | 40 |
|
30 | | -def test_standard_retry_fails_due_to_max_attempts() -> None: |
31 | | - retry_quota = StandardRetryQuota() |
32 | | - strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) |
33 | | - error = CallError(is_retry_safe=True) |
| 41 | + try: |
| 42 | + token = strategy.refresh_retry_token_for_retry( |
| 43 | + token_to_renew=token, error=error |
| 44 | + ) |
| 45 | + except RetryError: |
| 46 | + raise error |
34 | 47 |
|
35 | | - token = strategy.acquire_initial_retry_token() |
36 | 48 |
|
37 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
38 | | - assert token.retry_count == 1 |
39 | | - assert retry_quota.available_capacity == 495 |
| 49 | +async def test_standard_retry_eventually_succeeds(): |
| 50 | + quota = StandardRetryQuota(initial_capacity=500) |
| 51 | + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) |
40 | 52 |
|
41 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
42 | | - assert token.retry_count == 2 |
43 | | - assert retry_quota.available_capacity == 490 |
| 53 | + result, attempts = await retry_operation(strategy, [500, 500, 200]) |
44 | 54 |
|
45 | | - with pytest.raises(RetryError, match="maximum number of allowed attempts"): |
46 | | - strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
47 | | - assert retry_quota.available_capacity == 490 |
| 55 | + assert result == "success" |
| 56 | + assert attempts == 3 |
| 57 | + assert quota.available_capacity == 495 |
48 | 58 |
|
49 | 59 |
|
50 | | -def test_retry_quota_exhausted_after_single_retry() -> None: |
51 | | - retry_quota = StandardRetryQuota(initial_capacity=5) |
52 | | - strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) |
53 | | - error = CallError(is_retry_safe=True) |
| 60 | +async def test_standard_retry_fails_due_to_max_attempts(): |
| 61 | + quota = StandardRetryQuota(initial_capacity=500) |
| 62 | + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) |
54 | 63 |
|
55 | | - token = strategy.acquire_initial_retry_token() |
56 | | - assert retry_quota.available_capacity == 5 |
57 | | - |
58 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
59 | | - assert token.retry_count == 1 |
60 | | - assert retry_quota.available_capacity == 0 |
| 64 | + with pytest.raises(CallError, match="502"): |
| 65 | + await retry_operation(strategy, [502, 502, 502]) |
61 | 66 |
|
62 | | - with pytest.raises(RetryError, match="Retry quota exceeded"): |
63 | | - strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
64 | | - assert retry_quota.available_capacity == 0 |
| 67 | + assert quota.available_capacity == 490 |
65 | 68 |
|
66 | 69 |
|
67 | | -def test_retry_quota_prevents_retries_when_zero() -> None: |
68 | | - retry_quota = StandardRetryQuota(initial_capacity=0) |
69 | | - strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) |
70 | | - error = CallError(is_retry_safe=True) |
71 | | - |
72 | | - token = strategy.acquire_initial_retry_token() |
73 | | - assert retry_quota.available_capacity == 0 |
| 70 | +async def test_retry_quota_exhausted_after_single_retry(): |
| 71 | + quota = StandardRetryQuota(initial_capacity=5) |
| 72 | + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) |
74 | 73 |
|
75 | | - with pytest.raises(RetryError, match="Retry quota exceeded"): |
76 | | - strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
77 | | - assert retry_quota.available_capacity == 0 |
| 74 | + with pytest.raises(CallError, match="502"): |
| 75 | + await retry_operation(strategy, [500, 502]) |
78 | 76 |
|
| 77 | + assert quota.available_capacity == 0 |
79 | 78 |
|
80 | | -def test_retry_quota_stops_retries_when_exhausted() -> None: |
81 | | - retry_quota = StandardRetryQuota(initial_capacity=10) |
82 | | - strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) |
83 | | - error = CallError(is_retry_safe=True) |
84 | 79 |
|
85 | | - token = strategy.acquire_initial_retry_token() |
86 | | - assert retry_quota.available_capacity == 10 |
| 80 | +async def test_retry_quota_prevents_retries_when_quota_zero(): |
| 81 | + quota = StandardRetryQuota(initial_capacity=0) |
| 82 | + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) |
87 | 83 |
|
88 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
89 | | - assert retry_quota.available_capacity == 5 |
| 84 | + with pytest.raises(CallError, match="500"): |
| 85 | + await retry_operation(strategy, [500]) |
90 | 86 |
|
91 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
92 | | - assert retry_quota.available_capacity == 0 |
| 87 | + assert quota.available_capacity == 0 |
93 | 88 |
|
94 | | - with pytest.raises(RetryError, match="Retry quota exceeded"): |
95 | | - strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
96 | | - assert retry_quota.available_capacity == 0 |
97 | 89 |
|
| 90 | +async def test_retry_quota_stops_retries_when_exhausted(): |
| 91 | + quota = StandardRetryQuota(initial_capacity=10) |
| 92 | + strategy = StandardRetryStrategy(max_attempts=5, retry_quota=quota) |
98 | 93 |
|
99 | | -def test_retry_quota_recovers_after_successful_responses() -> None: |
100 | | - retry_quota = StandardRetryQuota(initial_capacity=15) |
101 | | - strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) |
102 | | - error = CallError(is_retry_safe=True) |
| 94 | + with pytest.raises(CallError, match="503"): |
| 95 | + await retry_operation(strategy, [500, 502, 503]) |
103 | 96 |
|
104 | | - # First operation: 2 retries then success |
105 | | - token = strategy.acquire_initial_retry_token() |
106 | | - assert retry_quota.available_capacity == 15 |
| 97 | + assert quota.available_capacity == 0 |
107 | 98 |
|
108 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
109 | | - assert retry_quota.available_capacity == 10 |
110 | 99 |
|
111 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
112 | | - assert retry_quota.available_capacity == 5 |
| 100 | +async def test_retry_quota_recovers_after_successful_responses(): |
| 101 | + quota = StandardRetryQuota(initial_capacity=15) |
| 102 | + strategy = StandardRetryStrategy(max_attempts=5, retry_quota=quota) |
113 | 103 |
|
114 | | - strategy.record_success(token=token) |
115 | | - assert retry_quota.available_capacity == 10 |
| 104 | + # First operation: 2 retries then success |
| 105 | + await retry_operation(strategy, [500, 502, 200]) |
| 106 | + assert quota.available_capacity == 10 |
116 | 107 |
|
117 | 108 | # Second operation: 1 retry then success |
118 | | - token = strategy.acquire_initial_retry_token() |
119 | | - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) |
120 | | - assert retry_quota.available_capacity == 5 |
121 | | - strategy.record_success(token=token) |
122 | | - assert retry_quota.available_capacity == 10 |
| 109 | + await retry_operation(strategy, [500, 200]) |
| 110 | + assert quota.available_capacity == 10 |
123 | 111 |
|
124 | 112 |
|
125 | | -def test_retry_quota_shared_correctly_across_multiple_operations() -> None: |
126 | | - retry_quota = StandardRetryQuota() |
127 | | - strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) |
128 | | - error = CallError(is_retry_safe=True) |
129 | | - |
130 | | - # Operation 1 |
131 | | - op1_token = strategy.acquire_initial_retry_token() |
132 | | - assert retry_quota.available_capacity == 500 |
133 | | - |
134 | | - op1_token = strategy.refresh_retry_token_for_retry( |
135 | | - token_to_renew=op1_token, error=error |
| 113 | +async def test_retry_quota_shared_across_concurrent_operations(): |
| 114 | + quota = StandardRetryQuota(initial_capacity=500) |
| 115 | + backoff = ExponentialRetryBackoffStrategy( |
| 116 | + backoff_scale_value=1, |
| 117 | + max_backoff=10, |
| 118 | + jitter_type=ExponentialBackoffJitterType.FULL, |
136 | 119 | ) |
137 | | - assert retry_quota.available_capacity == 495 |
138 | | - |
139 | | - op1_token = strategy.refresh_retry_token_for_retry( |
140 | | - token_to_renew=op1_token, error=error |
| 120 | + strategy = StandardRetryStrategy( |
| 121 | + max_attempts=5, |
| 122 | + retry_quota=quota, |
| 123 | + backoff_strategy=backoff, |
141 | 124 | ) |
142 | | - assert retry_quota.available_capacity == 490 |
143 | 125 |
|
144 | | - # Operation 2 (while operation 1 is in progress) |
145 | | - op2_token = strategy.acquire_initial_retry_token() |
146 | | - op2_token = strategy.refresh_retry_token_for_retry( |
147 | | - token_to_renew=op2_token, error=error |
| 126 | + result1, result2 = await gather( |
| 127 | + retry_operation(strategy, [500, 500, 200]), |
| 128 | + retry_operation(strategy, [500, 200]), |
148 | 129 | ) |
149 | | - assert retry_quota.available_capacity == 485 |
150 | | - |
151 | | - strategy.record_success(token=op2_token) |
152 | | - assert retry_quota.available_capacity == 490 |
153 | 130 |
|
154 | | - strategy.record_success(token=op1_token) |
155 | | - assert retry_quota.available_capacity == 495 |
| 131 | + assert result1 == ("success", 3) |
| 132 | + assert result2 == ("success", 2) |
| 133 | + assert quota.available_capacity == 495 |
0 commit comments