|
15 | 15 | """ |
16 | 16 | import unittest |
17 | 17 | from datetime import timezone, datetime, timedelta |
18 | | -from unittest.mock import Mock |
19 | | - |
20 | | -from botocore.exceptions import ClientError, NoCredentialsError, ReadTimeoutError |
| 18 | +from unittest.mock import Mock, patch |
21 | 19 |
|
22 | 20 | from aws_secretsmanager_caching.cache.items import SecretCacheObject, SecretCacheItem |
23 | 21 | from aws_secretsmanager_caching.config import SecretCacheConfig |
24 | 22 |
|
25 | 23 |
|
26 | | -def _client_error(code, status): |
27 | | - """Build a botocore ClientError with the given error code and HTTP status.""" |
28 | | - return ClientError( |
29 | | - {"Error": {"Code": code}, "ResponseMetadata": {"HTTPStatusCode": status}}, |
30 | | - "DescribeSecret", |
31 | | - ) |
32 | | - |
33 | | - |
34 | 24 | class TestSecretCacheObject(unittest.TestCase): |
35 | 25 |
|
36 | 26 | def setUp(self): |
@@ -109,72 +99,39 @@ def test_datetime_fix_refresh(self): |
109 | 99 | t_after = datetime.now(tz=timezone.utc) |
110 | 100 |
|
111 | 101 | t_before_delay = t_before + timedelta( |
112 | | - milliseconds=secret_cached_object._config.exception_retry_delay_base * ( |
| 102 | + seconds=secret_cached_object._config.exception_retry_delay_base * ( |
113 | 103 | secret_cached_object._config.exception_retry_growth_factor ** exp_factor |
114 | 104 | ) |
115 | 105 | ) |
116 | 106 | self.assertLessEqual(t_before_delay, secret_cached_object._next_retry_time) |
117 | 107 |
|
118 | 108 | t_after_delay = t_after + timedelta( |
119 | | - milliseconds=secret_cached_object._config.exception_retry_delay_base * ( |
| 109 | + seconds=secret_cached_object._config.exception_retry_delay_base * ( |
120 | 110 | secret_cached_object._config.exception_retry_growth_factor ** exp_factor |
121 | 111 | ) |
122 | 112 | ) |
123 | 113 | self.assertGreaterEqual(t_after_delay, secret_cached_object._next_retry_time) |
124 | 114 |
|
125 | | - def test_is_transient_error_classification(self): |
126 | | - # Permanent service errors (4xx) -- must not be retried. |
127 | | - for code in ("ResourceNotFoundException", "AccessDeniedException", |
128 | | - "InvalidParameterException", "DecryptionFailure", |
129 | | - "ValidationException"): |
130 | | - self.assertFalse( |
131 | | - SecretCacheObject._is_transient_error(_client_error(code, 400)), |
132 | | - f"{code} should be permanent") |
133 | | - |
134 | | - # Throttling is a 4xx but is transient -- must be retried. |
135 | | - self.assertTrue( |
136 | | - SecretCacheObject._is_transient_error(_client_error("ThrottlingException", 400))) |
137 | | - |
138 | | - # 5xx server-side errors are transient. |
139 | | - for code, status in (("InternalServiceError", 500), |
140 | | - ("InternalFailure", 500), |
141 | | - ("ServiceUnavailable", 503)): |
142 | | - self.assertTrue( |
143 | | - SecretCacheObject._is_transient_error(_client_error(code, status)), |
144 | | - f"{code} should be transient") |
145 | | - |
146 | | - # Transport-layer failures are transient; config errors are not. |
147 | | - self.assertTrue( |
148 | | - SecretCacheObject._is_transient_error(ReadTimeoutError(endpoint_url="https://x"))) |
149 | | - self.assertFalse(SecretCacheObject._is_transient_error(NoCredentialsError())) |
150 | | - |
151 | | - # Unknown error types default to transient, preserving the previous |
152 | | - # behavior of retrying any error we do not recognize as permanent. |
153 | | - self.assertTrue(SecretCacheObject._is_transient_error(KeyError("boom"))) |
154 | | - |
155 | | - def test_refresh_permanent_error_schedules_no_retry(self): |
| 115 | + @patch("aws_secretsmanager_caching.cache.items.time.sleep") |
| 116 | + def test_refresh_secret_now_with_pending_exception(self, mock_sleep): |
| 117 | + # Regression test: when a prior refresh failed, _next_retry_time holds a |
| 118 | + # datetime. The old code subtracted an int (current time in millis) from |
| 119 | + # that datetime, raising TypeError. refresh_secret_now() must instead |
| 120 | + # diff the two datetimes and sleep until the scheduled retry time. |
156 | 121 | sco = SecretCacheObject(SecretCacheConfig(), None, None) |
157 | | - sco._set_result = Mock(side_effect=_client_error("ResourceNotFoundException", 400)) |
158 | | - sco._refresh_needed = True |
159 | | - |
160 | | - sco._SecretCacheObject__refresh() |
161 | | - |
162 | | - self.assertIsNone(sco._next_retry_time) |
163 | | - self.assertFalse(sco._is_refresh_needed()) |
164 | | - self.assertIsNotNone(sco._exception) |
165 | | - |
166 | | - def test_refresh_permanent_error_clears_stale_retry_time(self): |
167 | | - # A permanent error following a transient one must clear the retry |
168 | | - # time left behind, otherwise the permanent error keeps being retried. |
169 | | - sco = SecretCacheObject(SecretCacheConfig(), None, None) |
170 | | - sco._next_retry_time = datetime.now(timezone.utc) - timedelta(seconds=1) |
171 | | - sco._set_result = Mock(side_effect=_client_error("ResourceNotFoundException", 400)) |
172 | | - sco._refresh_needed = True |
173 | | - |
174 | | - sco._SecretCacheObject__refresh() |
175 | | - |
176 | | - self.assertIsNone(sco._next_retry_time) |
177 | | - self.assertFalse(sco._is_refresh_needed()) |
| 122 | + sco._exception = Exception("prior refresh failure") |
| 123 | + sco._next_retry_time = datetime.now(timezone.utc) + timedelta(seconds=30) |
| 124 | + sco._execute_refresh = Mock() |
| 125 | + |
| 126 | + # Would have raised TypeError before the fix. |
| 127 | + sco.refresh_secret_now() |
| 128 | + |
| 129 | + # ~30s until retry -> ~30000ms; time.sleep() receives seconds (ms / 1000). |
| 130 | + mock_sleep.assert_called_once() |
| 131 | + slept_seconds = mock_sleep.call_args[0][0] |
| 132 | + self.assertGreater(slept_seconds, 25) |
| 133 | + self.assertLess(slept_seconds, 60) |
| 134 | + sco._execute_refresh.assert_called_once() |
178 | 135 |
|
179 | 136 |
|
180 | 137 | class TestSecretCacheItem(unittest.TestCase): |
|
0 commit comments