Skip to content

fix(auto-renewal-token): refresh access token on 401 and retry once so long-running syncs survive token expiry - #442

Merged
venkat102 merged 1 commit into
frappe:developfrom
aerele:fix/token-refresh-on-401
Jul 8, 2026
Merged

fix(auto-renewal-token): refresh access token on 401 and retry once so long-running syncs survive token expiry#442
venkat102 merged 1 commit into
frappe:developfrom
aerele:fix/token-refresh-on-401

Conversation

@sudarsan2001

@sudarsan2001 sudarsan2001 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

The Unicommerce API client (UnicommerceAPIClient) resolves an access token once at construction and reuses it for the entire lifetime of the client. Long-running jobs — most notably the new date-range old-order sync, which can run for hours — outlive the token's TTL. When the token expires mid-run, every subsequent call fails and the job aborts, even though the token was trivially refreshable.

This PR makes the client reactively refresh the token on an HTTP 401 and retry the failing request once, so in-flight jobs continue transparently.

Issue / Problem

File: ecommerce_integrations/unicommerce/api_client.py

  • __initialize_auth() sets self._auth_headers (the Bearer token) a single time when the client is created.
  • Token renewal was proactive only: renew_tokens() refreshes the token before a request, and only if expires_on has already passed at construction time.
  • request() had no handling for an auth failure at call time. On any non-2xx response, raise_for_status() raises, the exception is caught, and the method returns (None, False).

Consequence: for a job that constructs one client and loops for a long time (e.g. the old-order sync fetching 1000 orders/page and creating Sales Orders one by one), once the access token expires mid-run:

  • Every remaining request returns 401 → treated as a generic failure → (None, False).
  • The sync stops making progress and is reported as incomplete, requiring a manual re-run — despite a valid refresh token being available.

Fix

Both changes are scoped to api_client.py:

1. New _refresh_auth() helper

  • Calls settings.update_tokens(grant_type="refresh_token") to obtain a fresh access token and rebuilds self._auth_headers in memory.
  • Best-effort persistence: saves the refreshed token back to Unicommerce Settings (with ignore_permissions / ignore_custom_fields) so other clients/workers can reuse it. The save is wrapped in try/except — a persistence failure must not block the retry, which already holds the new token in memory — and logs via frappe.log_error(...) so repeated failures stay visible.

2. Reactive 401 handling in request()

  • On a 401, the method calls _refresh_auth(), re-applies the fresh header (headers.update(self._auth_headers)), and re-issues the request once. The retry is a single inline step — no recursion and no public parameter — so the guard cannot be bypassed by a caller, and it can never loop.
  • File uploads are not retried. When request() is called with files= (only create_import_job does this), requests streams the file object into the first request's body, leaving the handle at EOF. Replaying it on the retry would silently send an empty body. Such calls skip the refresh/retry path and fail loudly instead, so the upload can be re-run intact rather than losing data.

Behavior change

Scenario Before After
Token valid Request succeeds Request succeeds (unchanged)
Token expired mid-run 401(None, False), job aborts 401 → refresh + retry once → job continues
Refresh fails / 401 persists n/a Retried exactly once, then returns (None, False)
File upload hits 401 401(None, False) Not retried (stream already consumed) → (None, False), no silent empty upload

Tests

Added TestTokenRenewRetry in ecommerce_integrations/unicommerce/tests/test_client.py (using the responses mock), covering:

  • test_401_refreshes_token_and_retries_once — a 401 triggers exactly one refresh + retry, and the retry carries the freshly refreshed token.
  • test_persistent_401_gives_up_after_one_retry — a persistent 401 stops after a single retry (no infinite loop) and returns (None, False).
  • test_no_401_does_not_refresh — a successful response never touches the refresh path.
  • test_file_upload_401_is_not_retried — a files= request on 401 is not replayed and returns (None, False).

Notes / Considerations for reviewers

  • Bounded retry: the refresh + retry is a single inline step, so there is no risk of an infinite refresh/retry loop if the refresh itself fails or the 401 is not auth-TTL related.
  • Refresh-token expiry is already handled downstream: update_tokens(grant_type="refresh_token") falls back to the password grant via _handle_refresh_token_expiry() if the refresh token is itself expired (30-day rotation), so the refresh path is robust end-to-end.
  • Only 401 triggers the path; all other status codes and the existing error-logging behavior are unchanged. This is a backward-compatible change to the request flow.
  • Concurrency: update_tokens rotates the refresh token on each call. Concurrent refreshes from multiple workers could race on the persisted token; in practice the primary consumer (old-order sync) runs under a single-run lock, so this is not a concern for that job, but it's worth noting for other callers.

Backport needed in v15

@sudarsan2001
sudarsan2001 requested a review from ankush as a code owner July 1, 2026 12:52
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

Safe to merge — the retry is bounded, the file-upload guard is in place, and token refresh failures are logged without blocking the in-flight request.

The change is narrowly scoped to a single inline retry on 401. All previously flagged concerns have been addressed: the bare except now calls frappe.log_error, ignore_permissions carries an explanatory comment, the _auth_retried public-param anti-pattern was replaced with a direct inline retry, and file uploads are explicitly excluded from the retry path. No new issues are present.

No files require special attention.

Reviews (5): Last reviewed commit: "fix(auto-renewal-token): refresh access ..." | Re-trigger Greptile

Comment thread ecommerce_integrations/unicommerce/api_client.py Outdated
Comment thread ecommerce_integrations/unicommerce/api_client.py
Comment thread ecommerce_integrations/unicommerce/api_client.py Outdated
Comment thread ecommerce_integrations/unicommerce/api_client.py Outdated
@sudarsan2001
sudarsan2001 force-pushed the fix/token-refresh-on-401 branch from ec6d01b to 8eb0412 Compare July 2, 2026 06:03
@Bhavathariniya
Bhavathariniya force-pushed the fix/token-refresh-on-401 branch from 8eb0412 to 21592f1 Compare July 7, 2026 12:24
@sudarsan2001

Copy link
Copy Markdown
Contributor Author

Reviewed

@mergify

mergify Bot commented Jul 7, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@venkat102
venkat102 force-pushed the fix/token-refresh-on-401 branch from 21592f1 to a879473 Compare July 8, 2026 05:52
@venkat102
venkat102 merged commit e5ab9a8 into frappe:develop Jul 8, 2026
6 checks passed
@venkat102

Copy link
Copy Markdown
Member

@mergify backport to version-15

@mergify

mergify Bot commented Jul 8, 2026

Copy link
Copy Markdown

backport to version-15

❌ No backport have been created

Details
  • Backport to branch to failed

GitHub error: Branch not found

venkat102 added a commit that referenced this pull request Jul 8, 2026
fix(auto-renewal-token): refresh access token on 401 and retry once so long-running syncs survive token expiry (backport #442)
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.

2 participants