Skip to content

Commit 7f6fc68

Browse files
lvalicsclaude
andcommitted
Add token expiration tracking and auto-refresh functionality
- Add is_token_expired() method to check if token needs refresh with configurable buffer - Add refresh_token_if_needed() for automatic proactive token refresh before expiration - Add refresh_token() for manual forced token refresh (e.g., after permission changes) - Add token_obtained_at and token_expires_at tracking fields to Session class - request_token_client_credentials() now automatically stores expiration timestamps - Add 9 comprehensive test cases covering all expiration and refresh scenarios - Default 5-minute buffer before expiration to prevent authentication failures - Only works with client credentials flow (API versions >= 2026-01) - Backward compatible: gracefully handles authorization code flow without errors This enhancement prevents authentication failures in long-running processes by automatically refreshing tokens before they expire. Tokens from Shopify's client credentials grant expire after 24 hours (86,399 seconds). Example usage: session = shopify.Session('store.myshopify.com', '2026-01') session.request_access_token() # Later, before API calls result = session.refresh_token_if_needed() if result: print('Token was refreshed') Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent af0a274 commit 7f6fc68

3 files changed

Lines changed: 322 additions & 1 deletion

File tree

CHANGELOG

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
- Automatic version detection: API versions >= 2026-01 require client credentials flow
1010
- Legacy request_token() raises ValidationException for API versions >= 2026-01
1111
- Tokens from client credentials grant expire after 24 hours (86399 seconds)
12+
- Add token expiration tracking and automatic refresh functionality
13+
- New method: Session.is_token_expired() - Check if token is expired or expiring soon (with configurable buffer)
14+
- New method: Session.refresh_token_if_needed() - Automatically refresh token if expired or expiring within buffer time
15+
- New method: Session.refresh_token() - Manually force token refresh regardless of expiration status
16+
- Session now tracks token_obtained_at and token_expires_at timestamps for client credentials tokens
17+
- Default 5-minute buffer before expiration to ensure tokens are refreshed proactively
18+
- Auto-refresh only works with client credentials flow (API versions >= 2026-01)
1219

1320
== Version 12.7.0
1421

shopify/session.py

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import hmac
33
import json
44
from hashlib import sha256
5+
from datetime import datetime, timedelta
56

67
try:
78
import simplejson as json
@@ -56,6 +57,8 @@ def __init__(self, shop_url, version=None, token=None, access_scopes=None):
5657
self.token = token
5758
self.version = ApiVersion.coerce_to_version(version)
5859
self.access_scopes = access_scopes
60+
self.token_expires_at = None
61+
self.token_obtained_at = None
5962
return
6063

6164
def _requires_client_credentials(self):
@@ -215,11 +218,16 @@ def request_token_client_credentials(self):
215218
self.token = json_payload["access_token"]
216219
self.access_scopes = json_payload.get("scope", "")
217220

221+
# Store expiration tracking information
222+
expires_in = json_payload.get("expires_in", 86399)
223+
self.token_obtained_at = datetime.now()
224+
self.token_expires_at = self.token_obtained_at + timedelta(seconds=expires_in)
225+
218226
# Return full response for caller
219227
return {
220228
"access_token": self.token,
221229
"scope": self.access_scopes,
222-
"expires_in": json_payload.get("expires_in", 86399)
230+
"expires_in": expires_in
223231
}
224232
else:
225233
raise OAuthException("OAuth request failed with status %d: %s" % (response.code, response.msg))
@@ -288,6 +296,158 @@ def request_access_token(self, params=None):
288296
)
289297
return self.request_token(params)
290298

299+
def is_token_expired(self, buffer_seconds=300):
300+
"""
301+
Check if access token is expired or will expire soon.
302+
303+
This method checks whether the current access token has expired or will
304+
expire within the specified buffer time. It's useful for proactively
305+
refreshing tokens before they expire to avoid authentication failures.
306+
307+
Args:
308+
buffer_seconds (int): Number of seconds before expiration to consider
309+
the token as "expired". Default is 300 (5 minutes).
310+
This buffer allows time to refresh the token before
311+
it actually expires.
312+
313+
Returns:
314+
bool: True if:
315+
- No token is set
316+
- No expiration time is available
317+
- Token has expired
318+
- Token will expire within buffer_seconds
319+
False if token is valid and won't expire soon
320+
321+
Example:
322+
>>> session = shopify.Session("store.myshopify.com", "2026-01")
323+
>>> session.request_token_client_credentials()
324+
>>> if session.is_token_expired():
325+
... session.refresh_token()
326+
>>> # Or check with custom buffer (10 minutes)
327+
>>> if session.is_token_expired(buffer_seconds=600):
328+
... session.refresh_token()
329+
"""
330+
# No token set - consider expired
331+
if not self.token:
332+
return True
333+
334+
# No expiration tracking available - consider expired for safety
335+
if not self.token_expires_at:
336+
return True
337+
338+
# Calculate if token is expired or expiring soon
339+
now = datetime.now()
340+
buffer = timedelta(seconds=buffer_seconds)
341+
342+
# Token is expired if current time + buffer >= expiration time
343+
return now + buffer >= self.token_expires_at
344+
345+
def refresh_token_if_needed(self, buffer_seconds=300):
346+
"""
347+
Automatically refresh the access token if expired or expiring soon.
348+
349+
This method checks if the token is expired or will expire within the buffer
350+
time, and automatically refreshes it if needed. It's the recommended way to
351+
ensure you always have a valid token without manually tracking expiration.
352+
353+
Args:
354+
buffer_seconds (int): Number of seconds before expiration to trigger
355+
refresh. Default is 300 (5 minutes). This ensures
356+
the token is refreshed before it expires.
357+
358+
Returns:
359+
dict or None:
360+
- dict: Token response if refresh was performed, containing:
361+
- access_token (str): The new access token
362+
- scope (str): Comma-separated list of granted scopes
363+
- expires_in (int): Seconds until token expires
364+
- None: If token is still valid and no refresh was needed
365+
366+
Raises:
367+
ValidationException: If required credentials are missing
368+
OAuthException: If OAuth refresh request fails
369+
370+
Example:
371+
>>> session = shopify.Session("store.myshopify.com", "2026-01")
372+
>>> shopify.Session.setup(api_key="client_id", secret="client_secret")
373+
>>>
374+
>>> # Initial token request
375+
>>> session.request_token_client_credentials()
376+
>>>
377+
>>> # Later, before making API calls, ensure token is fresh
378+
>>> result = session.refresh_token_if_needed()
379+
>>> if result:
380+
... print("Token was refreshed")
381+
... else:
382+
... print("Token is still valid")
383+
>>>
384+
>>> # Use custom buffer (refresh if expires within 10 minutes)
385+
>>> session.refresh_token_if_needed(buffer_seconds=600)
386+
"""
387+
if self.is_token_expired(buffer_seconds=buffer_seconds):
388+
# Only refresh if we have credentials (client credentials flow)
389+
if self._requires_client_credentials():
390+
return self.request_token_client_credentials()
391+
else:
392+
# For authorization code flow, we can't auto-refresh
393+
# because we need user interaction for the callback
394+
return None
395+
396+
# Token is still valid, no refresh needed
397+
return None
398+
399+
def refresh_token(self):
400+
"""
401+
Manually force a refresh of the access token.
402+
403+
This method unconditionally refreshes the access token, regardless of
404+
whether it has expired or not. Use this when you need to force a token
405+
refresh (e.g., after permission changes, for testing, or if you suspect
406+
the token is invalid).
407+
408+
For automatic refresh based on expiration, use refresh_token_if_needed()
409+
instead.
410+
411+
Returns:
412+
dict: Token response containing:
413+
- access_token (str): The new access token
414+
- scope (str): Comma-separated list of granted scopes
415+
- expires_in (int): Seconds until token expires (typically 86399)
416+
417+
Raises:
418+
ValidationException: If required credentials are missing or if this
419+
method is called on a session using authorization
420+
code flow (requires user interaction)
421+
OAuthException: If OAuth refresh request fails
422+
423+
Example:
424+
>>> session = shopify.Session("store.myshopify.com", "2026-01")
425+
>>> shopify.Session.setup(api_key="client_id", secret="client_secret")
426+
>>>
427+
>>> # Initial token request
428+
>>> session.request_token_client_credentials()
429+
>>>
430+
>>> # Force token refresh (e.g., after permission changes)
431+
>>> new_token = session.refresh_token()
432+
>>> print(f"New token: {new_token['access_token']}")
433+
>>> print(f"Expires in: {new_token['expires_in']} seconds")
434+
"""
435+
# Only works with client credentials flow
436+
if not self._requires_client_credentials():
437+
raise ValidationException(
438+
"Manual token refresh is only supported for API versions >= 2026-01 "
439+
"using client credentials flow. For authorization code flow, "
440+
"users must re-authorize through the OAuth callback."
441+
)
442+
443+
# Clear existing token to force new request
444+
self.token = None
445+
self.token_expires_at = None
446+
self.token_obtained_at = None
447+
448+
# Request new token
449+
return self.request_token_client_credentials()
450+
291451
@property
292452
def api_version(self):
293453
return self.version

test/session_test.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,157 @@ def test_request_access_token_uses_authorization_code_for_old_version(self):
471471
token = session.request_access_token(params)
472472

473473
self.assertEqual(token, "old_style_token")
474+
475+
def test_is_token_expired_with_no_token(self):
476+
"""Test that is_token_expired returns True when no token is set"""
477+
session = shopify.Session("testshop.myshopify.com", "2026-01")
478+
self.assertTrue(session.is_token_expired())
479+
480+
def test_is_token_expired_with_no_expiration(self):
481+
"""Test that is_token_expired returns True when token has no expiration tracking"""
482+
session = shopify.Session("testshop.myshopify.com", "2026-01")
483+
session.token = "test_token"
484+
# token_expires_at is None
485+
self.assertTrue(session.is_token_expired())
486+
487+
def test_is_token_expired_within_buffer(self):
488+
"""Test that is_token_expired returns True when token expires within buffer"""
489+
from datetime import datetime, timedelta
490+
491+
session = shopify.Session("testshop.myshopify.com", "2026-01")
492+
session.token = "test_token"
493+
494+
# Set token to expire in 4 minutes
495+
session.token_expires_at = datetime.now() + timedelta(minutes=4)
496+
497+
# With default 5-minute buffer, should be considered expired
498+
self.assertTrue(session.is_token_expired())
499+
500+
# With 3-minute buffer, should not be considered expired
501+
self.assertFalse(session.is_token_expired(buffer_seconds=180))
502+
503+
def test_is_token_not_expired(self):
504+
"""Test that is_token_expired returns False when token is valid"""
505+
from datetime import datetime, timedelta
506+
507+
session = shopify.Session("testshop.myshopify.com", "2026-01")
508+
session.token = "test_token"
509+
510+
# Set token to expire in 1 hour
511+
session.token_expires_at = datetime.now() + timedelta(hours=1)
512+
513+
# With default 5-minute buffer, should not be expired
514+
self.assertFalse(session.is_token_expired())
515+
516+
def test_refresh_token_if_needed_refreshes_expired_token(self):
517+
"""Test that refresh_token_if_needed refreshes an expired token"""
518+
from datetime import datetime, timedelta
519+
520+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
521+
session = shopify.Session("testshop.myshopify.com", "2026-01")
522+
session.token = "old_token"
523+
524+
# Set token as expired
525+
session.token_expires_at = datetime.now() - timedelta(minutes=1)
526+
527+
self.fake(
528+
None,
529+
url="https://testshop.myshopify.com/admin/oauth/access_token",
530+
method="POST",
531+
body='{"access_token": "new_token", "scope": "read_products", "expires_in": 86399}',
532+
has_user_agent=False,
533+
)
534+
535+
result = session.refresh_token_if_needed()
536+
537+
# Should return token response
538+
self.assertIsNotNone(result)
539+
self.assertEqual(result["access_token"], "new_token")
540+
self.assertEqual(session.token, "new_token")
541+
542+
def test_refresh_token_if_needed_does_not_refresh_valid_token(self):
543+
"""Test that refresh_token_if_needed does not refresh a valid token"""
544+
from datetime import datetime, timedelta
545+
546+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
547+
session = shopify.Session("testshop.myshopify.com", "2026-01")
548+
session.token = "valid_token"
549+
550+
# Set token to expire in 1 hour
551+
session.token_expires_at = datetime.now() + timedelta(hours=1)
552+
553+
result = session.refresh_token_if_needed()
554+
555+
# Should return None (no refresh needed)
556+
self.assertIsNone(result)
557+
self.assertEqual(session.token, "valid_token")
558+
559+
def test_refresh_token_forces_refresh(self):
560+
"""Test that refresh_token forces a token refresh"""
561+
from datetime import datetime, timedelta
562+
563+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
564+
session = shopify.Session("testshop.myshopify.com", "2026-01")
565+
session.token = "old_valid_token"
566+
567+
# Set token to expire in 1 hour (still valid)
568+
session.token_expires_at = datetime.now() + timedelta(hours=1)
569+
570+
self.fake(
571+
None,
572+
url="https://testshop.myshopify.com/admin/oauth/access_token",
573+
method="POST",
574+
body='{"access_token": "forced_new_token", "scope": "read_products", "expires_in": 86399}',
575+
has_user_agent=False,
576+
)
577+
578+
result = session.refresh_token()
579+
580+
# Should force refresh even though token was valid
581+
self.assertEqual(result["access_token"], "forced_new_token")
582+
self.assertEqual(session.token, "forced_new_token")
583+
584+
def test_refresh_token_fails_for_old_api_version(self):
585+
"""Test that refresh_token raises error for API versions < 2026-01"""
586+
shopify.Session.setup(api_key="test_key", secret="test_secret")
587+
session = shopify.Session("testshop.myshopify.com", "2025-10")
588+
session.token = "old_version_token"
589+
590+
with self.assertRaises(shopify.ValidationException) as context:
591+
session.refresh_token()
592+
593+
self.assertIn("2026-01", str(context.exception))
594+
self.assertIn("client credentials flow", str(context.exception))
595+
596+
def test_token_expiration_tracking_stored(self):
597+
"""Test that token expiration tracking is properly stored"""
598+
from datetime import datetime, timedelta
599+
600+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
601+
session = shopify.Session("testshop.myshopify.com", "2026-01")
602+
603+
before_request = datetime.now()
604+
605+
self.fake(
606+
None,
607+
url="https://testshop.myshopify.com/admin/oauth/access_token",
608+
method="POST",
609+
body='{"access_token": "test_token", "scope": "read_products", "expires_in": 86399}',
610+
has_user_agent=False,
611+
)
612+
613+
session.request_token_client_credentials()
614+
615+
after_request = datetime.now()
616+
617+
# Verify expiration tracking is set
618+
self.assertIsNotNone(session.token_obtained_at)
619+
self.assertIsNotNone(session.token_expires_at)
620+
621+
# Verify obtained_at is between before and after
622+
self.assertGreaterEqual(session.token_obtained_at, before_request)
623+
self.assertLessEqual(session.token_obtained_at, after_request)
624+
625+
# Verify expires_at is approximately 24 hours from obtained_at
626+
expected_expiration = session.token_obtained_at + timedelta(seconds=86399)
627+
self.assertEqual(session.token_expires_at, expected_expiration)

0 commit comments

Comments
 (0)