Skip to content

fix: add token expiry buffer to prevent expired token usage and lock concurrent refreshes#283

Open
SoulPancake wants to merge 6 commits intomainfrom
fix/token-expiry-buffer
Open

fix: add token expiry buffer to prevent expired token usage and lock concurrent refreshes#283
SoulPancake wants to merge 6 commits intomainfrom
fix/token-expiry-buffer

Conversation

@SoulPancake
Copy link
Copy Markdown
Member

@SoulPancake SoulPancake commented Apr 28, 2026

Description

Mirrors the same bug fix as in the JS sdk openfga/js-sdk#331
Tokens were being used until the exact moment of expiry. Added an expiry buffer (with jitter) so tokens are refreshed before they expire, preventing in-flight requests from hitting the server with an expired token.

Under burst concurrency, multiple coroutines/threads could simultaneously see the token as invalid and all fetch a new one. Added asyncio.Lock (async) and threading.Lock (sync) with double-checked locking so only one refresh happens and the rest reuse the result.

What problem is being solved?

How is it being solved?

What changes are made to solve it?

References

Review Checklist

  • I have clicked on "allow edits by maintainers".
  • I have added documentation for new/changed functionality in this PR or in a PR to openfga.dev [Provide a link to any relevant PRs in the references section above]
  • The correct base branch is being used, if not main
  • I have added tests to validate that the change in functionality is working as expected

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • OAuth2 tokens are now refreshed with improved expiry detection using configurable buffer thresholds
    • Concurrent authentication requests are now properly synchronized to prevent duplicate token refreshes
  • Tests

    • Added comprehensive test coverage for token refresh behavior near expiration and concurrent request handling scenarios

@SoulPancake SoulPancake requested a review from a team as a code owner April 28, 2026 15:47
Copilot AI review requested due to automatic review settings April 28, 2026 15:47
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 69.96%. Comparing base (f07861e) to head (aebd088).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #283      +/-   ##
==========================================
+ Coverage   69.91%   69.96%   +0.04%     
==========================================
  Files         140      140              
  Lines       10764    10779      +15     
==========================================
+ Hits         7526     7541      +15     
  Misses       3238     3238              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

Warning

Rate limit exceeded

@SoulPancake has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 20 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: be25d343-5163-4c19-9451-c0cc43fdcf60

📥 Commits

Reviewing files that changed from the base of the PR and between 7e1cd8b and aebd088.

📒 Files selected for processing (4)
  • openfga_sdk/oauth2.py
  • openfga_sdk/sync/oauth2.py
  • test/oauth2_test.py
  • test/sync/oauth2_test.py

Walkthrough

This PR implements proactive token expiry handling in both async and synchronous OAuth2 clients with configurable buffer and randomized jitter. Concurrency control via asyncio.Lock and threading.Lock prevents redundant token refreshes when multiple coroutines or threads request authentication headers simultaneously.

Changes

Cohort / File(s) Summary
OAuth2 Implementation
openfga_sdk/oauth2.py, openfga_sdk/sync/oauth2.py
Modified token validation to compute remaining token lifetime against a configurable buffer threshold plus randomized jitter instead of direct expiry checks. Added concurrency control (asyncio.Lock for async, threading.Lock for sync) with double-checked locking pattern to prevent redundant token refreshes. Introduced expiry buffer state management and adjusted scope string formatting.
OAuth2 Test Suite
test/oauth2_test.py, test/sync/oauth2_test.py
Added tests validating proactive token refresh behavior when token lifetime falls within the expiry buffer window. Introduced concurrency tests confirming that multiple simultaneous authentication requests trigger only a single token fetch via locking synchronization, with all callers receiving the same refreshed token. Adjusted existing token expiry setup to remain outside the buffer threshold.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • emilic
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main changes: adding a token expiry buffer and implementing locking for concurrent refresh operations. Both features are core to this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/token-expiry-buffer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the SDK’s OAuth2 client (async + sync) to refresh access tokens before they reach their exact expiry time (using a proactive buffer with jitter) and to prevent concurrent refresh stampedes via locking, mirroring a prior fix in the JS SDK.

Changes:

  • Add a proactive “near-expiry” buffer (with jitter) to token validity checks.
  • Add double-checked locking (asyncio.Lock / threading.Lock) so only one refresh happens under concurrency.
  • Extend unit tests to cover near-expiry refresh behavior and concurrent refresh scenarios.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
openfga_sdk/oauth2.py Adds proactive expiry buffer + asyncio lock around token refresh.
openfga_sdk/sync/oauth2.py Adds proactive expiry buffer + threading lock around token refresh.
test/oauth2_test.py Adds async tests for near-expiry refresh and concurrent refresh behavior.
test/sync/oauth2_test.py Adds sync tests for near-expiry refresh and concurrent refresh behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread openfga_sdk/oauth2.py Outdated
Comment thread openfga_sdk/sync/oauth2.py Outdated
Comment thread test/oauth2_test.py
Comment thread test/sync/oauth2_test.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
openfga_sdk/oauth2.py (1)

146-153: ⚠️ Potential issue | 🟠 Major

Clamp the proactive buffer to the token lifetime.

This window is always 300–600 seconds, so a provider that returns a shorter-lived token is considered invalid again immediately after refresh. The next get_authentication_header() call will fetch a new token instead of reusing the one it just obtained. Bound the buffer to expires_in before storing it.

Suggested fix
-                    self._access_expiry_time = datetime.now() + timedelta(
-                        seconds=int(api_response.get("expires_in"))
-                    )
-                    self._access_token = api_response.get("access_token")
-                    self._access_token_expiry_buffer = (
-                        TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC
-                        + random.random() * TOKEN_EXPIRY_JITTER_IN_SEC
-                    )
+                    expires_in = int(api_response.get("expires_in"))
+                    expiry_buffer = min(
+                        TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC
+                        + random.random() * TOKEN_EXPIRY_JITTER_IN_SEC,
+                        max(0, expires_in - 1),
+                    )
+                    self._access_expiry_time = datetime.now() + timedelta(
+                        seconds=expires_in
+                    )
+                    self._access_token_expiry_buffer = expiry_buffer
+                    self._access_token = api_response.get("access_token")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openfga_sdk/oauth2.py` around lines 146 - 153, The proactive expiry buffer is
currently set to TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC +
random.random()*TOKEN_EXPIRY_JITTER_IN_SEC regardless of the token's lifetime,
so clamp that buffer to the actual expires_in from api_response before storing
_access_token_expiry_buffer; compute expires =
int(api_response.get("expires_in")), compute desired_buffer =
TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC +
random.random()*TOKEN_EXPIRY_JITTER_IN_SEC, then set _access_token_expiry_buffer
= min(desired_buffer, max(0, expires - 1)) (or another small floor) so
get_authentication_header() reuses tokens shorter than the jittered window and
_access_expiry_time/_access_token remain consistent.
openfga_sdk/sync/oauth2.py (1)

147-154: ⚠️ Potential issue | 🟠 Major

Compute a bounded buffer before publishing the refreshed token.

This buffer is always 300–600 seconds, so any provider that returns a shorter-lived token becomes “expired” again immediately and every later header lookup re-hits the token endpoint. In the sync client there is also a race here: another thread can observe Lines 147-150 before Line 151 updates the buffer, because _token_valid() runs outside the lock. Compute a clamped buffer first and publish _access_token last so readers never see a stale validity window.

Suggested fix
-                    self._access_expiry_time = datetime.now() + timedelta(
-                        seconds=int(api_response.get("expires_in"))
-                    )
-                    self._access_token = api_response.get("access_token")
-                    self._access_token_expiry_buffer = (
-                        TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC
-                        + random.random() * TOKEN_EXPIRY_JITTER_IN_SEC
-                    )
+                    expires_in = int(api_response.get("expires_in"))
+                    expiry_buffer = min(
+                        TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC
+                        + random.random() * TOKEN_EXPIRY_JITTER_IN_SEC,
+                        max(0, expires_in - 1),
+                    )
+                    self._access_expiry_time = datetime.now() + timedelta(
+                        seconds=expires_in
+                    )
+                    self._access_token_expiry_buffer = expiry_buffer
+                    self._access_token = api_response.get("access_token")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openfga_sdk/sync/oauth2.py` around lines 147 - 154, The code sets
_access_expiry_time and _access_token before computing
_access_token_expiry_buffer which can cause immediate re-expiry and a race where
_token_valid() (called outside the lock) sees an inconsistent window; fix by
computing a clamped buffer value first (use TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC
and TOKEN_EXPIRY_JITTER_IN_SEC, clamp it so it does not exceed the token
lifetime from api_response["expires_in"]), then set _access_expiry_time, set
_access_token_expiry_buffer to that precomputed value, and finally publish
_access_token (all within the same synchronized block) so readers using
_token_valid() never observe a stale validity window.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/sync/oauth2_test.py`:
- Around line 502-517: The test currently only asserts token fetch happened once
and that all collected results are the expected header, but it doesn't ensure
all worker threads completed successfully; update the test around
oauth_client.get_authentication_header, mock_obtain_token, obtain_calls and
results to assert that all 5 threads produced a result (e.g., assert
len(results) == 5) or capture and re-raise thread exceptions so a failing thread
will fail the test, ensuring the concurrency path is actually exercised by all
threads.

---

Outside diff comments:
In `@openfga_sdk/oauth2.py`:
- Around line 146-153: The proactive expiry buffer is currently set to
TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC +
random.random()*TOKEN_EXPIRY_JITTER_IN_SEC regardless of the token's lifetime,
so clamp that buffer to the actual expires_in from api_response before storing
_access_token_expiry_buffer; compute expires =
int(api_response.get("expires_in")), compute desired_buffer =
TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC +
random.random()*TOKEN_EXPIRY_JITTER_IN_SEC, then set _access_token_expiry_buffer
= min(desired_buffer, max(0, expires - 1)) (or another small floor) so
get_authentication_header() reuses tokens shorter than the jittered window and
_access_expiry_time/_access_token remain consistent.

In `@openfga_sdk/sync/oauth2.py`:
- Around line 147-154: The code sets _access_expiry_time and _access_token
before computing _access_token_expiry_buffer which can cause immediate re-expiry
and a race where _token_valid() (called outside the lock) sees an inconsistent
window; fix by computing a clamped buffer value first (use
TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC and TOKEN_EXPIRY_JITTER_IN_SEC, clamp it so
it does not exceed the token lifetime from api_response["expires_in"]), then set
_access_expiry_time, set _access_token_expiry_buffer to that precomputed value,
and finally publish _access_token (all within the same synchronized block) so
readers using _token_valid() never observe a stale validity window.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e84e7f2a-99b7-466d-b203-7a8a9dac093b

📥 Commits

Reviewing files that changed from the base of the PR and between f07861e and 7e1cd8b.

📒 Files selected for processing (4)
  • openfga_sdk/oauth2.py
  • openfga_sdk/sync/oauth2.py
  • test/oauth2_test.py
  • test/sync/oauth2_test.py

Comment thread test/sync/oauth2_test.py
ttrzeng
ttrzeng previously approved these changes Apr 28, 2026
Replace three separate mutable fields with a single frozen dataclass
assigned atomically, ensuring concurrent threads always see a complete
token state snapshot.
@SoulPancake
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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