Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/aws_secretsmanager_caching/cache/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,15 @@ def __refresh(self):
self._set_result(self._execute_refresh())
self._exception = None
self._exception_count = 0
self._next_retry_time = None
except Exception as e: # pylint: disable=broad-except
self._exception = e
delay = self._config.exception_retry_delay_base * (
self._config.exception_retry_growth_factor ** self._exception_count
)
self._exception_count += 1
delay = min(delay, self._config.exception_retry_delay_max)
self._next_retry_time = datetime.now(timezone.utc) + timedelta(milliseconds=delay)
self._next_retry_time = datetime.now(timezone.utc) + timedelta(seconds=delay)

def get_secret_value(self, version_stage=None):
"""Get the cached secret value for the given version stage.
Expand Down Expand Up @@ -134,15 +135,18 @@ def refresh_secret_now(self):
# Generate a random number to have a sleep jitter to not get stuck in a retry loop
sleep = randint(int(self.FORCE_REFRESH_JITTER_SLEEP / 2), self.FORCE_REFRESH_JITTER_SLEEP + 1)

if self._exception is not None:
current_time_millis = int(datetime.now(timezone.utc).timestamp() * 1000)
exception_sleep = self._next_retry_time - current_time_millis
if self._exception is not None and self._next_retry_time is not None:
now = datetime.now(timezone.utc)
exception_sleep = (self._next_retry_time - now).total_seconds() * 1000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A minor issue here - exception sleep can be negative which Ideally should be 0 if next_retry_time hasn't been reached yet. The PR description captures the change as

exception_sleep = max((self._next_retry_time - now).total_seconds() * 1000, 0)

Which doesn't match the implementation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is okay since we have sleep = max(exception_sleep, sleep) which will always be at a minimum of line 135.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. For the minimum number it will choose the random number. I had that line in the code before, but then saw sleep = max(exception_sleep, sleep) and then removed it.

sleep = max(exception_sleep, sleep)

# Divide by 1000 for millis
time.sleep(sleep / 1000)

self._execute_refresh()
# Refresh under the lock: __refresh stores the result and resets exception/backoff
# state on success, or records the exception and schedules a retry on failure.
with self._lock:
self.__refresh()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we opted for the __refresh() method here, we're changing the customer behavior. __refresh() has broad exception handling, meaning a failure in refreshing the secret now is swallowed.

If we pull inspiration from the Java caching library, it uses a bool to signal to the customer whether a refresh successful or not. I think this is a decent approach, what do you think?


def _get_result(self):
"""Get the stored result using a hook if present"""
Expand Down
29 changes: 26 additions & 3 deletions test/unit/test_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"""
import unittest
from datetime import timezone, datetime, timedelta
from unittest.mock import Mock
from unittest.mock import Mock, patch

from aws_secretsmanager_caching.cache.items import SecretCacheObject, SecretCacheItem
from aws_secretsmanager_caching.config import SecretCacheConfig
Expand Down Expand Up @@ -99,19 +99,42 @@ def test_datetime_fix_refresh(self):
t_after = datetime.now(tz=timezone.utc)

t_before_delay = t_before + timedelta(
milliseconds=secret_cached_object._config.exception_retry_delay_base * (
seconds=secret_cached_object._config.exception_retry_delay_base * (
secret_cached_object._config.exception_retry_growth_factor ** exp_factor
)
)
self.assertLessEqual(t_before_delay, secret_cached_object._next_retry_time)

t_after_delay = t_after + timedelta(
milliseconds=secret_cached_object._config.exception_retry_delay_base * (
seconds=secret_cached_object._config.exception_retry_delay_base * (
secret_cached_object._config.exception_retry_growth_factor ** exp_factor
)
)
self.assertGreaterEqual(t_after_delay, secret_cached_object._next_retry_time)

@patch("aws_secretsmanager_caching.cache.items.time.sleep")
def test_force_refresh_with_retry_pending(self, mock_sleep):
# With a retry pending, refresh_secret_now() should not raise, should update the
# cache, and should clear the recorded exception/backoff state on success.
sco = SecretCacheObject(SecretCacheConfig(), None, None)
sco._execute_refresh = Mock(side_effect=Exception("exception used for test"))
sco._refresh_needed = True

sco._SecretCacheObject__refresh()
self.assertIsNotNone(sco._exception)
self.assertIsNotNone(sco._next_retry_time)

sco._execute_refresh = Mock(return_value="refreshed")
sco.refresh_secret_now() # would have raised TypeError before the fix

sco._execute_refresh.assert_called_once()
# the fetched value is stored in the cache rather than discarded
self.assertEqual(sco._get_result(), "refreshed")
# a successful forced refresh clears the recorded exception and backoff state
self.assertIsNone(sco._exception)
self.assertEqual(sco._exception_count, 0)
self.assertIsNone(sco._next_retry_time)


class TestSecretCacheItem(unittest.TestCase):
def setUp(self):
Expand Down
Loading