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:
+87
-1
@@ -1,6 +1,22 @@
|
||||
import functools
|
||||
from typing import Any, Callable, TypeVar, cast
|
||||
|
||||
from mdrsclient.api.users import UsersApi
|
||||
from mdrsclient.connection import MDRSConnection
|
||||
from mdrsclient.exceptions import UnauthorizedException
|
||||
from mdrsclient.exceptions import (
|
||||
MDRSException,
|
||||
ServerBusyException,
|
||||
TokenExpiredException,
|
||||
UnauthorizedException,
|
||||
)
|
||||
from mdrsclient.models import Token
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
SERVER_BUSY_MESSAGE = (
|
||||
"The server took too long to start handling the request and may be overloaded. "
|
||||
"Try again, or reduce the number of parallel transfers."
|
||||
)
|
||||
|
||||
|
||||
def token_check(connection: MDRSConnection) -> None:
|
||||
@@ -36,3 +52,73 @@ def token_check(connection: MDRSConnection) -> None:
|
||||
connection.token = user_api.tokenRefresh(token)
|
||||
except UnauthorizedException:
|
||||
connection.logout()
|
||||
|
||||
|
||||
def token_recover(connection: MDRSConnection, used: Token | None) -> bool:
|
||||
"""
|
||||
Get a usable access token after one was refused for having expired.
|
||||
|
||||
A request can sit in the server's queue for longer than its access token lives, and
|
||||
while it waited another thread or process may already have refreshed. Prefer what
|
||||
they left behind: refreshing again would spend a round trip, and with a provider
|
||||
that rotates refresh tokens it would retire one that is still in use.
|
||||
|
||||
Returns whether the caller now holds a token worth retrying with.
|
||||
"""
|
||||
with connection.lock, connection.cache_lock():
|
||||
connection.reload_cache()
|
||||
token = connection.token
|
||||
if token is None:
|
||||
return False
|
||||
if used is None or token != used:
|
||||
# Somebody else has been here since the request went out. Assign rather than
|
||||
# just read: the setter is what rewrites the session's Authorization header,
|
||||
# so a plain reload would leave the retry carrying the refused token.
|
||||
connection.token = token
|
||||
return True
|
||||
user_api = UsersApi(connection)
|
||||
try:
|
||||
connection.token = user_api.tokenRefresh(token)
|
||||
except UnauthorizedException:
|
||||
connection.logout()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def retry_on_expired_token(func: F) -> F:
|
||||
"""
|
||||
Send a request again once when the server found its access token expired.
|
||||
|
||||
The token is checked before every request, so this only happens when the request was
|
||||
held long enough on the way in for a valid token to lapse - a queue behind uploads
|
||||
that take minutes, most often.
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
connection: MDRSConnection = self.connection
|
||||
# Settle the token first so `used` is what the request actually carries. The
|
||||
# wrapped method checks it again, which costs nothing once it is already current.
|
||||
token_check(connection)
|
||||
used = connection.token
|
||||
try:
|
||||
return func(self, *args, **kwargs)
|
||||
except TokenExpiredException:
|
||||
try:
|
||||
recovered = token_recover(connection, used)
|
||||
except MDRSException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# A busy server queues the refresh too, and failing here would report an
|
||||
# endpoint the user never asked for rather than the reason.
|
||||
raise ServerBusyException(SERVER_BUSY_MESSAGE) from e
|
||||
if not recovered:
|
||||
raise
|
||||
try:
|
||||
return func(self, *args, **kwargs)
|
||||
except TokenExpiredException as e:
|
||||
# A second refusal, with a token that was current when it left. The session is
|
||||
# fine; the server is not starting requests before their credentials lapse.
|
||||
raise ServerBusyException(SERVER_BUSY_MESSAGE) from e
|
||||
|
||||
return cast(F, wrapper)
|
||||
|
||||
Reference in New Issue
Block a user