fix: use seconds for retry backoff and fix refresh_secret_now datetime math - #72
fix: use seconds for retry backoff and fix refresh_secret_now datetime math#72SaiTejaKundety wants to merge 7 commits into
Conversation
derik01
left a comment
There was a problem hiding this comment.
I'm pretty sure this should error out too: https://github.com/aws/aws-secretsmanager-caching-python/blob/master/src/aws_secretsmanager_caching/cache/items.py#L139
| ) | ||
| self._exception_count += 1 | ||
| delay = min(delay, self._config.exception_retry_delay_max) | ||
| self._next_retry_time = datetime.now(timezone.utc) + timedelta(milliseconds=delay) |
There was a problem hiding this comment.
I think a real bug here (not caused by you) is that the README calls out secret_refresh_interval - The number of seconds to wait between refreshing cached secret information. The default value is 3600.0.. However, we can see that we instead use milliseconds. So at a maximum, we retry at 3.6 seconds.
b696d3c to
c87f6be
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #72 +/- ##
==========================================
+ Coverage 98.14% 99.26% +1.11%
==========================================
Files 8 8
Lines 270 272 +2
==========================================
+ Hits 265 270 +5
+ Misses 5 2 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Drive a real failure through __refresh() so _next_retry_time is set by production code, then confirm the forced refresh walks that path without raising TypeError. Drops the brittle sleep-duration assertions; the seconds-based backoff is already covered by test_datetime_fix_refresh.
Since we missed the TypeError when we first implemented it, I'm a little curious what the remaining lines are. |
| current_time_millis = int(datetime.now(timezone.utc).timestamp() * 1000) | ||
| exception_sleep = self._next_retry_time - current_time_millis | ||
| now = datetime.now(timezone.utc) | ||
| exception_sleep = max((self._next_retry_time - now).total_seconds() * 1000, 0) |
There was a problem hiding this comment.
Why do we need the max function?
EDIT: also, should we gate the if statement on if self._next_retry_time is not None?
There was a problem hiding this comment.
in case (self._next_retry_time - now) is ever negative, this will just pick 0. however, in the next line it has a max between exception_sleep & sleep and sleep can never be negative so we can remove this portion (its redundant).
There was a problem hiding this comment.
For your gating question: I think we can gate on both exception & next_retry time and it would be the safest option
There was a problem hiding this comment.
I see that we are not clearing the exception if a refresh of the secret is successful. I feel like if a refresh is successful we can also reset the exception counter. I think the Java caching library does something similar. I also noticed that we have a __refresh function. Maybe we can re-use that?
Let me know what you think about that.
There was a problem hiding this comment.
+1 to Derik. Additionally:
1/ Since _execute_refresh is invoked here instead of __refresh, I believe we are never actually updating the cache with the updated value.
2/ I feel this method reads/writes shared state like _refresh_needed, _exception, _next_retry_time but never acquires a lock (ref get_secret_value).
I'd like your thoughts on this. I also understand this seems to be an existing bug so I am ok fixing this in a separate PR too.
There was a problem hiding this comment.
Yup, I think both of you are correct. This is something that should be changed
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I think this is okay since we have sleep = max(exception_sleep, sleep) which will always be at a minimum of line 135.
There was a problem hiding this comment.
+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.
There was a problem hiding this comment.
+1 to Derik. Additionally:
1/ Since _execute_refresh is invoked here instead of __refresh, I believe we are never actually updating the cache with the updated value.
2/ I feel this method reads/writes shared state like _refresh_needed, _exception, _next_retry_time but never acquires a lock (ref get_secret_value).
I'd like your thoughts on this. I also understand this seems to be an existing bug so I am ok fixing this in a separate PR too.
derik01
left a comment
There was a problem hiding this comment.
Customers can wait up to an 1 hour for their secret to refresh now.
| # 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() |
There was a problem hiding this comment.
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?
Description
Why is this change being made?
The exception-retry path in SecretCacheObject had several bugs that made the refresh backoff behave incorrectly:
subtracted an int (current time in millis) from it: exception_sleep = self._next_retry_time - current_time_millis.
Subtracting an int from a datetime raises TypeError, so any forced refresh while an exception retry was scheduled
crashed instead of sleeping until the retry time.
discarded the result, unlike __refresh() which stores it via _set_result(). The fetched
secret was thrown away, leaving the stale value cached.
after success.
_next_retry_time, cached result) without self._lock, unlike get_secret_value() — a race
under concurrent access.
What is changing?
datetimes and takes the larger of the retry wait and the jitter sleep:
The jitter floors the wait, so a stale or negative delta can never shorten it below the normal jitter.
cache instead of discarded.
What has changed since the last revision
Addressed review feedback on the forced-refresh path:
refresh_secret_now()now delegates to__refresh(), so the fetched value is stored in the cache via_set_result()instead of being discarded.self._lock, matchingget_secret_value(), removing the race on shared state.__refresh()now clears_next_retry_time(alongside_exceptionand_exception_count) on a successful refresh, so stale backoff state is no longer carried forward.test_force_refresh_with_retry_pendingto also assert the cache is updated and the exception/backoff state is reset on success.refresh_secret_now()no longer raises; the error surfaces on the nextget_secret_value()).Related Links
Testing
How was this tested?
milliseconds.
When testing locally, provide testing artifact(s):
$ pytest test/unit
43 passed
Required test coverage of 90% reached. Total coverage: 99.26%
$ flake8
(clean)
$ pylint --rcfile=.pylintrc src/aws_secretsmanager_caching
Your code has been rated at 10.00/10
Reviewee Checklist
Update the checklist after submitting the PR
If not, why:
If not, why:
If not, why:
If not, why:
If not, why:
If not, why: Not needed
If not, why:
If not, why:
If not, why: Not needed
If not, why: No API/signature changes. Behavior is corrected: retry backoff now honors the seconds-based config (was 1000× too fast — capped at 3.6s instead of 1 hour), and refresh_secret_now() no longer raises TypeError when a retry is pending. One behavior change: a failed refresh_secret_now() no longer raises — the error is recorded and surfaces on the next get_secret_value(), consistent with the normal refresh path. Customers could wait up to an hour for their secret to refresh
Reviewer Checklist
All reviewers please ensure the following are true before reviewing:
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.