Skip to content

feat(python): add ElastiCacheIAMAuthTokenManager (auto-refresh) - #15

Merged
mwaisaida-13 merged 2 commits into
release-2from
python/auto-refresh
Sep 22, 2026
Merged

mwaisaida-13 merged 2 commits into
release-2from
python/auto-refresh

Conversation

@mwaisaida-13

@mwaisaida-13 mwaisaida-13 commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds ElastiCacheIAMAuthTokenManager: a thread-safe cache around the stateless
ElastiCacheIAMAuthTokenProvider that refreshes the token on a background daemon
thread refresh_after seconds after signing (default 300).

ElastiCacheIAMAuthTokenProvider ElastiCacheIAMAuthTokenManager
Token per get_token() Freshly signed every call Cached token while valid
Refresh Caller-driven Background, refresh_after after signing
Refresh failures Raised to the caller Retried; current token still served; warning logged
Change notification None on_token_changed
Cleanup None needed close() / context manager

Behaviour

  • Config validated and region resolved at construction; first token signed lazily.
  • A token is served for 880 s after signing (900 s lifetime minus a 20 s margin for
    clock skew and the AUTH round trip); refresh_after must be below 880. Expiry is
    tracked on the wall clock, since the server judges X-Amz-Date against its own
    wall clock and a monotonic clock stands still through a host suspend.
  • get_token() returns the cached token while valid; a token past its refresh due
    time (missed timer) is returned immediately and refreshed in the background.
  • Single flight: concurrent callers with no valid token share one signing cycle and
    receive the same result or failure (concurrent.futures.Future).
  • Retry: up to 8 attempts, exponential backoff from 100 ms with a nominal 5 s cap,
    ±20 % jitter. Every error is retried, including ConfigurationError: on an
    instance role a transient IMDS failure surfaces as "no credentials".
  • While the cached token is valid a failed cycle is invisible to callers: a warning
    is logged and another cycle is scheduled after the max backoff (get_token() does
    not restart it early). With no token the last error is raised; with an expired
    token TokenRefreshError is raised with the last error as __cause__.
  • on_token_changed(token) is called synchronously, outside the lock, for each new
    token; a callback failure is logged and the token kept.
  • close() cancels the timer, wakes a sleeping retry, drops the cached secret;
    later calls raise TokenRefreshError. Idempotent.
  • get_credentials() returns (user_id, token) — the no-arg provider hook.

Also in this change

  • TokenRefreshError (not a ToolkitInputError), exported with the manager and
    DEFAULT_REFRESH_AFTER_SECONDS.
  • Tests: shared fakes moved to tests/helpers.py / tests/conftest.py; 53 manager
    tests with injected clock/scheduler/sleep/jitter (no real timers), plus
    real-thread single-flight tests (success and failure sharing).
  • README "Caching and background refresh" section, CHANGELOG Unreleased entry.

Verification

  • python scripts/python_dev.py ci — lint clean, 128 passed, wheel + sdist built,
    twine check --strict passed. 128 passed on 3.10 as well.
  • Built wheel installed in a clean venv exports the new API.
  • Two independent code reviews (concurrency, token hygiene, retry semantics, 3.10);
    all findings addressed with regression tests.
  • Live 90-minute soak against a serverless cache: one connection, 0 reconnects, 90/90 GETs, 18 background refreshes at an exact 300 s cadence. A second 90-minute soak on this exact revision is running now; result to follow in a comment.

Notes for reviewers

  • Five noqas, each with a reason on the line: PLR0913 (7 keyword-only args),
    PYI034 (typing.Self is 3.11+; we support 3.10), BLE001 ×3 (retry any error,
    scheduling-failure guard, timer-thread catch).
  • The library logs nothing except one WARNING per exhausted refresh cycle and per
    on_token_changed exception. Note that botocore.auth at DEBUG level logs the
    SigV4 signature, i.e. the token — a README security note for that is a follow-up.

…auth tokens

The stateless ElastiCacheIAMAuthTokenProvider signs on every call. The manager
wraps it with a thread-safe cache and refreshes the token on a background daemon
thread `refresh_after` seconds after signing (default 300), so clients that
reconnect often or run connection pools always get a valid token without signing
per call.

Behaviour
- Config validated and region resolved eagerly at construction; first token
  signed lazily on the first get_token()/get_credentials().
- A token is served for 880s after signing: the 900s lifetime minus a 20s margin
  for clock skew and the AUTH round trip. refresh_after must be below 880.
  Expiry is tracked on the wall clock, since the server judges X-Amz-Date against
  its own wall clock and a monotonic clock stands still through a host suspend.
- get_token() returns the cached token while it is valid. A token found past its
  refresh due time (missed timer, suspended process) is returned immediately and
  refreshed in the background; callers holding a valid token never wait.
- Single flight: concurrent callers with no valid token share one signing cycle
  and receive the same result or the same failure (concurrent.futures.Future).
- Retry: up to 8 attempts, exponential backoff from 100ms with a nominal 5s cap,
  +/-20% jitter. Every error is retried, including ConfigurationError: on an
  instance role a transient IMDS failure surfaces as "no credentials".
- Failure semantics: while the cached token is valid a failed cycle is invisible
  to callers, logs a warning, and schedules another cycle after the maximum
  backoff (get_token() does not restart it early); with no token the last error
  is raised; with an expired token TokenRefreshError is raised with the last
  error as __cause__.
- on_token_changed(token) is called synchronously outside the lock for each newly
  installed token; a callback failure is logged and the token kept.
- close() (or the context manager) cancels the timer, wakes a sleeping retry,
  drops the cached secret, and makes later calls raise TokenRefreshError.
  Idempotent. A scheduling failure never loses a signed token.

Also
- TokenRefreshError (not a ToolkitInputError) added to errors and exported with
  the manager and DEFAULT_REFRESH_AFTER_SECONDS.
- _CachedToken keeps the bearer token out of its repr.
- Tests: shared session fake and fixtures moved to tests/helpers.py and
  tests/conftest.py; 53 manager tests with injected clock, scheduler, sleep and
  jitter so no real timers run, plus real-thread single-flight tests.
- README "Caching and background refresh" section and CHANGELOG entry.
@mwaisaida-13
mwaisaida-13 marked this pull request as ready for review September 21, 2026 11:52
try:
self._on_token_changed(token)
except Exception: # a callback failure must not discard the token
_logger.warning("on_token_changed callback raised", exc_info=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Because the callback receives the bearer token, its exception message or chained exception may contain that token. Should emit a sanitized message

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

good catch, will remove

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in e407df0: the log line now carries only the exception type, no message or traceback. Test asserts the callback's message text is absent from the log.

Comment thread python/tests/test_token_manager.py
Comment thread python/src/developer_toolkit_elasticache/token_manager.py Outdated
Comment thread python/src/developer_toolkit_elasticache/token_manager.py Outdated
Comment thread python/src/developer_toolkit_elasticache/token_manager.py
Comment thread python/tests/test_token_manager.py
Comment thread python/tests/test_token_manager.py Outdated
Comment thread python/tests/test_token_manager.py
Comment thread python/tests/test_token_manager.py Outdated
Comment thread python/tests/test_token_manager.py
- Callback failures are logged by exception type only: the callback received the
  token, so its message or traceback might contain it.
- Re-check expiry under the lock before serving the still-valid token at the end
  of a failed cycle; a waiter that joined because its token expired must not get
  it back if the clock crossed expires_at while the lock was released.
- Complete and clear the signing flight before calling on_token_changed, so a
  timer that fires during a slow callback starts its own cycle instead of joining
  the old one and skipping the reschedule. Reuse decisions now compare token
  identity rather than issued_at.
- Add refresh_token(): sign a replacement immediately for a client whose AUTH was
  rejected with a token the manager still considers valid; raises
  TokenRefreshError if none could be signed.
- Tests: daemon workers with liveness asserts; shared-flight tests hold the owner
  open until every waiter has joined (join-counting Future); re-entrant callback
  test runs bounded; real _wait interrupted while blocked; real-timer end-to-end
  test with a callback slower than refresh_after; regression tests for the two
  races above.

@edlng edlng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

:shipit:

@mwaisaida-13
mwaisaida-13 merged commit 200944c into release-2 Sep 22, 2026
14 checks passed
@mwaisaida-13
mwaisaida-13 deleted the python/auto-refresh branch October 13, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants