feat(auth): send a request again when its token lapsed in the queue

A request can wait to be served for longer than the access token it was
sent with lives, and comes back refused for a token that was valid when
it left. Uploads that take minutes make that wait ordinary.

- tell an expired token apart from any other refusal by the code the
  API returns, and retry only that one, once
- prefer a token another process left behind over minting a second: it
  saves a round trip, and a rotating provider would retire one that is
  still in use. Assign it rather than read it, since the setter is what
  rewrites the session header the retry will carry
- settle the token before the request so the one compared afterwards is
  the one that was actually sent
- report an overloaded server when the second attempt is refused too,
  and let API errors out of the upload path with their own type
This commit is contained in:
2026-08-14 18:37:41 +09:00
parent 0cac30ccf8
commit e5c28835b8
9 changed files with 226 additions and 10 deletions
+84 -2
View File
@@ -5,11 +5,11 @@ from unittest.mock import patch
import jwt
from mdrsclient.api.utils import token_check
from mdrsclient.api.utils import retry_on_expired_token, token_check, token_recover
from mdrsclient.cache import CacheFile
from mdrsclient.config import normalize_url
from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import IllegalArgumentException
from mdrsclient.exceptions import IllegalArgumentException, ServerBusyException, TokenExpiredException
from mdrsclient.models import Token
REMOTE = "unittest"
@@ -112,5 +112,87 @@ class TestUrlNormalization(unittest.TestCase):
normalize_url(url)
class TestExpiredTokenRetry(unittest.TestCase):
"""A request can wait in the server's queue for longer than its access token lives."""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
patcher = patch("mdrsclient.cache.CONFIG_DIRNAME", self.tmp.name)
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(self.tmp.cleanup)
self.connection = MDRSConnection(REMOTE, "http://localhost:8000/api")
self.connection.token = make_token(3600, 86400, "sent")
def test_a_token_someone_else_refreshed_is_reused_rather_than_replaced(self) -> None:
used = self.connection.token
rotated = make_token(3600, 86400, "rotated")
CacheFile(REMOTE).token = rotated # another process, while the request waited
with patch("mdrsclient.api.utils.UsersApi") as users_api:
recovered = token_recover(self.connection, used)
self.assertTrue(recovered)
users_api.assert_not_called()
self.assertEqual(self.connection.token, rotated)
# The retry carries whatever the session header says, not what the cache holds,
# so reading the new token through is not enough on its own.
self.assertEqual(self.connection.session.headers["Authorization"], f"Bearer {rotated.access}")
def test_the_token_is_refreshed_when_nobody_else_has(self) -> None:
used = self.connection.token
rotated = make_token(3600, 86400, "rotated")
with patch("mdrsclient.api.utils.UsersApi") as users_api:
users_api.return_value.tokenRefresh.return_value = rotated
recovered = token_recover(self.connection, used)
self.assertTrue(recovered)
users_api.return_value.tokenRefresh.assert_called_once()
self.assertEqual(self.connection.token, rotated)
def test_the_request_is_sent_once_more_and_only_once(self) -> None:
calls: list[str] = []
class Api:
def __init__(self, connection: MDRSConnection) -> None:
self.connection = connection
@retry_on_expired_token
def send(self) -> str:
calls.append("sent")
if len(calls) == 1:
raise TokenExpiredException("expired on arrival")
return "ok"
with patch("mdrsclient.api.utils.UsersApi") as users_api:
users_api.return_value.tokenRefresh.return_value = make_token(3600, 86400, "rotated")
self.assertEqual(Api(self.connection).send(), "ok")
self.assertEqual(len(calls), 2)
def test_a_second_refusal_is_reported_as_an_overloaded_server(self) -> None:
calls: list[str] = []
class Api:
def __init__(self, connection: MDRSConnection) -> None:
self.connection = connection
@retry_on_expired_token
def send(self) -> str:
calls.append("sent")
raise TokenExpiredException("expired on arrival")
with patch("mdrsclient.api.utils.UsersApi") as users_api:
users_api.return_value.tokenRefresh.return_value = make_token(3600, 86400, "rotated")
with self.assertRaises(ServerBusyException) as caught:
Api(self.connection).send()
# Sent twice and no more, and the message names the cause the user can act on
# rather than an authentication failure that would send them to the login form.
self.assertEqual(len(calls), 2)
self.assertIn("overloaded", str(caught.exception))
if __name__ == "__main__":
unittest.main()