Skip to content

Commit b696d3c

Browse files
author
SaiTejaKundety
committed
fix: use seconds for retry backoff and fix refresh_secret_now datetime math
1 parent 2046a3e commit b696d3c

2 files changed

Lines changed: 30 additions & 124 deletions

File tree

src/aws_secretsmanager_caching/cache/items.py

Lines changed: 8 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@
2020
from datetime import datetime, timedelta, timezone
2121
from random import randint
2222

23-
from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError, NoRegionError
24-
2523
from .lru import LRUCache
2624

2725

@@ -53,50 +51,6 @@ def __init__(self, config, client, secret_id):
5351
self._refresh_needed = True
5452
self._next_retry_time = None
5553

56-
# Throttling is the only transient error that carries a 4xx status, so it
57-
# must be matched by error code rather than by the status check below.
58-
_THROTTLING_ERROR_CODES = frozenset({
59-
"ThrottlingException",
60-
"TooManyRequestsException",
61-
"Throttling",
62-
})
63-
64-
@staticmethod
65-
def _is_transient_error(e):
66-
"""Determine whether a refresh exception is transient (worth retrying).
67-
68-
Only configuration errors and clear 4xx client errors (e.g.
69-
ResourceNotFound, AccessDenied) are permanent. Everything else --
70-
timeouts, throttling, 5xx, and any unrecognized error -- is retried,
71-
preserving the previous behavior for errors we do not classify.
72-
73-
:type e: Exception
74-
:param e: The exception raised during refresh.
75-
76-
:rtype: bool
77-
:return: True if the error is transient and should be retried.
78-
"""
79-
# Config errors are BotoCoreErrors but can never succeed on retry.
80-
if isinstance(e, (NoCredentialsError, NoRegionError)):
81-
return False
82-
83-
# Other BotoCoreErrors are transport failures (timeouts, connection).
84-
if isinstance(e, BotoCoreError):
85-
return True
86-
87-
# Service errors: permanent only for a clear 4xx (throttling excepted).
88-
if isinstance(e, ClientError):
89-
code = e.response.get("Error", {}).get("Code", "")
90-
if code in SecretCacheObject._THROTTLING_ERROR_CODES:
91-
return True
92-
status = e.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
93-
if status is not None and 400 <= status < 500:
94-
return False
95-
return True
96-
97-
# Unrecognized error type: retry (preserves previous behavior).
98-
return True
99-
10054
def _is_refresh_needed(self):
10155
"""Determine if the cached object should be refreshed.
10256
@@ -145,17 +99,12 @@ def __refresh(self):
14599
self._exception_count = 0
146100
except Exception as e: # pylint: disable=broad-except
147101
self._exception = e
148-
if self._is_transient_error(e):
149-
delay = self._config.exception_retry_delay_base * (
150-
self._config.exception_retry_growth_factor ** self._exception_count
151-
)
152-
self._exception_count += 1
153-
delay = min(delay, self._config.exception_retry_delay_max)
154-
self._next_retry_time = datetime.now(timezone.utc) + timedelta(milliseconds=delay)
155-
else:
156-
# Clear any retry time left over from a prior transient failure
157-
# so a permanent error is not automatically retried.
158-
self._next_retry_time = None
102+
delay = self._config.exception_retry_delay_base * (
103+
self._config.exception_retry_growth_factor ** self._exception_count
104+
)
105+
self._exception_count += 1
106+
delay = min(delay, self._config.exception_retry_delay_max)
107+
self._next_retry_time = datetime.now(timezone.utc) + timedelta(seconds=delay)
159108

160109
def get_secret_value(self, version_stage=None):
161110
"""Get the cached secret value for the given version stage.
@@ -186,8 +135,8 @@ def refresh_secret_now(self):
186135
sleep = randint(int(self.FORCE_REFRESH_JITTER_SLEEP / 2), self.FORCE_REFRESH_JITTER_SLEEP + 1)
187136

188137
if self._exception is not None:
189-
current_time_millis = int(datetime.now(timezone.utc).timestamp() * 1000)
190-
exception_sleep = self._next_retry_time - current_time_millis
138+
now = datetime.now(timezone.utc)
139+
exception_sleep = max((self._next_retry_time - now).total_seconds() * 1000, 0)
191140
sleep = max(exception_sleep, sleep)
192141

193142
# Divide by 1000 for millis

test/unit/test_items.py

Lines changed: 22 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,12 @@
1515
"""
1616
import unittest
1717
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
2119

2220
from aws_secretsmanager_caching.cache.items import SecretCacheObject, SecretCacheItem
2321
from aws_secretsmanager_caching.config import SecretCacheConfig
2422

2523

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-
3424
class TestSecretCacheObject(unittest.TestCase):
3525

3626
def setUp(self):
@@ -109,72 +99,39 @@ def test_datetime_fix_refresh(self):
10999
t_after = datetime.now(tz=timezone.utc)
110100

111101
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 * (
113103
secret_cached_object._config.exception_retry_growth_factor ** exp_factor
114104
)
115105
)
116106
self.assertLessEqual(t_before_delay, secret_cached_object._next_retry_time)
117107

118108
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 * (
120110
secret_cached_object._config.exception_retry_growth_factor ** exp_factor
121111
)
122112
)
123113
self.assertGreaterEqual(t_after_delay, secret_cached_object._next_retry_time)
124114

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.
156121
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()
178135

179136

180137
class TestSecretCacheItem(unittest.TestCase):

0 commit comments

Comments
 (0)