Skip to content

Commit 5ae5dcd

Browse files
authored
fix: distinguish permanent and retryable token validation failures (#7546)
* fix: distinguish permanent and retryable token validation failures * fix: mark rejected tokens as not retryable
1 parent f10cff4 commit 5ae5dcd

4 files changed

Lines changed: 198 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Authentication validation now reports the HTTP status when the control plane refuses the request, and only suggests trying again later for failures that may succeed on retry (timeouts, HTTP 429 and server errors).

‎packages/reflex-hosting-cli/src/reflex_cli/utils/exceptions.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,34 @@ class TokenValidationError(ReflexHostingCliError):
1616
surface it for correlation with server-side logs.
1717
"""
1818

19-
def __init__(self, message: str, request_id: str = ""):
19+
def __init__(self, message: str, request_id: str = "", retryable: bool = True):
2020
"""Initialize the error.
2121
2222
Args:
2323
message: The error message.
2424
request_id: The X-Request-ID header sent with the failed request.
25+
retryable: Whether trying again later may succeed. False when the
26+
control plane refused the request itself.
2527
"""
2628
super().__init__(message)
2729
self.request_id = request_id
30+
self.retryable = retryable
2831

2932

3033
class TokenAccessDeniedError(TokenValidationError, ValueError):
31-
"""Raised when the control plane rejects the access token."""
34+
"""Raised when the control plane rejects the access token.
35+
36+
Trying again with the same token cannot succeed, so it is never retryable.
37+
"""
38+
39+
def __init__(self, message: str, request_id: str = ""):
40+
"""Initialize the error.
41+
42+
Args:
43+
message: The error message.
44+
request_id: The X-Request-ID header sent with the failed request.
45+
"""
46+
super().__init__(message, request_id, retryable=False)
3247

3348

3449
class GetAppError(ReflexHostingCliError):

‎packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ def rejected_token_message(source: TokenSource, err: TokenAccessDeniedError) ->
503503
def _token_validation_failure_message(
504504
source: TokenSource, err: TokenValidationError
505505
) -> str:
506-
"""Describe a temporary validation failure and suggest retrying.
506+
"""Describe a validation failure, suggesting a retry only if one may help.
507507
508508
Args:
509509
source: Where the token was loaded from.
@@ -512,6 +512,11 @@ def _token_validation_failure_message(
512512
Returns:
513513
The message to report.
514514
"""
515+
if not err.retryable:
516+
return (
517+
f"Unable to validate the access token from the {source.value}: "
518+
f"{err} (auth request id: {err.request_id})."
519+
)
515520
return (
516521
f"Unable to validate the access token from the {source.value}: "
517522
f"{err} (auth request id: {err.request_id}). Please try again later."
@@ -757,6 +762,26 @@ def new_client(token: str | None = None) -> ReflexBuild:
757762
)
758763

759764

765+
def _is_retryable_status(status_code: int) -> bool:
766+
"""Tell whether a refused request may succeed if sent again later.
767+
768+
Args:
769+
status_code: The status the control plane answered with.
770+
771+
Returns:
772+
True for a timeout, a throttled request or a server error.
773+
"""
774+
return (
775+
status_code
776+
in (
777+
HTTPStatus.REQUEST_TIMEOUT,
778+
HTTPStatus.TOO_EARLY,
779+
HTTPStatus.TOO_MANY_REQUESTS,
780+
)
781+
or status_code >= HTTPStatus.INTERNAL_SERVER_ERROR
782+
)
783+
784+
760785
def _validate(token: str, api: ReflexBuild | None = None) -> Me:
761786
"""Ask the control plane who an access token authenticates as.
762787
@@ -770,7 +795,9 @@ def _validate(token: str, api: ReflexBuild | None = None) -> Me:
770795
Raises:
771796
TokenAccessDeniedError: If the token was refused.
772797
TokenValidationError: If the answer could not be had -- a timeout, a
773-
failed request, a server error. These are worth trying again.
798+
failed request, a server error, or a request the control plane
799+
refused. Only the first kinds are worth trying again, as
800+
``retryable`` says.
774801
775802
"""
776803
global _last_auth_request_id
@@ -786,10 +813,16 @@ def _validate(token: str, api: ReflexBuild | None = None) -> Me:
786813
raise TokenAccessDeniedError(
787814
"access denied", request_id=ex.request_id
788815
) from ex
816+
retryable = _is_retryable_status(ex.status_code)
817+
# The API's explanation is not shown or logged: it may echo the
818+
# request, including the token.
819+
message = f"HTTP {ex.status_code}"
789820
logger.debug(
790-
f"Unable to validate the token due to: {ex} (request id: {ex.request_id})"
821+
f"Unable to validate the token due to: {message} (request id: {ex.request_id})"
791822
)
792-
raise TokenValidationError("server error", request_id=ex.request_id) from ex
823+
raise TokenValidationError(
824+
message, request_id=ex.request_id, retryable=retryable
825+
) from ex
793826
except APIError as ex:
794827
_last_auth_request_id = ex.request_id
795828
logger.debug(

‎tests/units/reflex_cli/utils/test_hosting.py‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,149 @@ def test_validate_token_with_retries_access_denied_reports_request_id(
11221122
assert get_auth_request_id() in errors[-1]
11231123

11241124

1125+
@pytest.mark.parametrize(
1126+
("status_code", "retryable"),
1127+
[
1128+
(400, False),
1129+
(404, False),
1130+
(422, False),
1131+
(408, True),
1132+
(429, True),
1133+
(500, True),
1134+
(503, True),
1135+
],
1136+
)
1137+
def test_validate_reports_the_status_of_a_refused_request(
1138+
status_code: int, retryable: bool
1139+
):
1140+
"""A refused validation request reports its status and whether to retry.
1141+
1142+
Args:
1143+
status_code: The status the API answered with.
1144+
retryable: Whether trying again later may help.
1145+
"""
1146+
client = _client()
1147+
client.api.auth.me.side_effect = api_error(
1148+
status_code, "Malformed authentication request"
1149+
)
1150+
1151+
with pytest.raises(TokenValidationError) as exc_info:
1152+
hosting._validate("fictional-token", client.api)
1153+
1154+
assert not isinstance(exc_info.value, TokenAccessDeniedError)
1155+
assert str(exc_info.value) == f"HTTP {status_code}"
1156+
assert exc_info.value.retryable is retryable
1157+
1158+
1159+
@pytest.mark.parametrize("status_code", [401, 403])
1160+
def test_validate_does_not_call_a_rejected_token_retryable(status_code: int):
1161+
"""A token the control plane refused is not worth trying again.
1162+
1163+
Args:
1164+
status_code: The status the API answered with.
1165+
"""
1166+
client = _client()
1167+
client.api.auth.me.side_effect = api_error(status_code, "denied")
1168+
1169+
with pytest.raises(TokenAccessDeniedError) as exc_info:
1170+
hosting._validate("fictional-token", client.api)
1171+
1172+
assert exc_info.value.retryable is False
1173+
assert isinstance(exc_info.value, TokenValidationError)
1174+
1175+
1176+
def test_token_access_denied_error_is_never_retryable():
1177+
"""The error is not retryable however it is built."""
1178+
assert TokenAccessDeniedError("access denied").retryable is False
1179+
assert TokenAccessDeniedError("access denied", "req-1").retryable is False
1180+
assert TokenValidationError("server error").retryable is True
1181+
1182+
1183+
@pytest.mark.parametrize("error", [APIConnectionError, APITimeoutError])
1184+
def test_validate_retries_connection_failures(error: type[APIConnectionError]):
1185+
"""A request that never got an answer is worth trying again.
1186+
1187+
Args:
1188+
error: The connection failure the SDK raises.
1189+
"""
1190+
client = _client()
1191+
client.api.auth.me.side_effect = error("no answer", request=MagicMock())
1192+
1193+
with pytest.raises(TokenValidationError) as exc_info:
1194+
hosting._validate("fictional-token", client.api)
1195+
1196+
assert exc_info.value.retryable is True
1197+
1198+
1199+
@pytest.mark.parametrize(
1200+
"detail",
1201+
[
1202+
"Invalid token fictional-token-abc123",
1203+
"Authorization: Bearer fictional-token-abc123 was malformed",
1204+
"fictional%2Dtoken%2Dabc123",
1205+
"fictional\\u002dtoken\\u002dabc123",
1206+
"fictional-\ntoken-abc123",
1207+
"Basic ZmljdGlvbmFsOnBhc3M= api_key=other-credential-xyz password=hunter2",
1208+
"x" * 5000,
1209+
],
1210+
)
1211+
def test_validate_does_not_surface_the_api_detail(
1212+
caplog: pytest.LogCaptureFixture, detail: str
1213+
):
1214+
"""The API's explanation, which may echo the request, is never shown or logged.
1215+
1216+
Args:
1217+
caplog: Pytest log capture fixture.
1218+
detail: An explanation that echoes what the request carried.
1219+
"""
1220+
caplog.set_level(logging.DEBUG)
1221+
client = _client()
1222+
client.api.auth.me.side_effect = api_error(400, detail)
1223+
1224+
with pytest.raises(TokenValidationError) as exc_info:
1225+
hosting._validate("fictional-token-abc123", client.api)
1226+
1227+
assert str(exc_info.value) == "HTTP 400"
1228+
for record in caplog.records:
1229+
message = record.getMessage()
1230+
assert detail[:20] not in message
1231+
assert "abc123" not in message
1232+
assert "other-credential-xyz" not in message
1233+
assert "hunter2" not in message
1234+
1235+
1236+
@pytest.mark.parametrize(
1237+
("status_code", "advises_retry"), [(400, False), (429, True), (503, True)]
1238+
)
1239+
def test_non_interactive_validation_failure_advises_retry_only_when_it_may_help(
1240+
mocker: MockerFixture,
1241+
caplog: pytest.LogCaptureFixture,
1242+
status_code: int,
1243+
advises_retry: bool,
1244+
):
1245+
"""Only a failure worth trying again is told to try again later.
1246+
1247+
Args:
1248+
mocker: Pytest mocker fixture.
1249+
caplog: Pytest log capture fixture.
1250+
status_code: The status the API answered with.
1251+
advises_retry: Whether the report suggests trying again.
1252+
"""
1253+
client = _client()
1254+
client.api.auth.me.side_effect = api_error(status_code, "Malformed request")
1255+
mocker.patch("reflex_cli.utils.hosting.new_client", return_value=client.api)
1256+
delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config")
1257+
1258+
with pytest.raises(click.exceptions.Exit):
1259+
get_authenticated_client(token="fictional-token", interactive=False)
1260+
1261+
delete.assert_not_called()
1262+
errors = [r.getMessage() for r in caplog.records if r.levelno == logging.ERROR]
1263+
assert len(errors) == 1
1264+
assert f"HTTP {status_code} (auth request id:" in errors[0]
1265+
assert ("Please try again later." in errors[0]) is advises_retry
1266+
1267+
11251268
def test_validate_token_failure_carries_request_id_on_exception(
11261269
mocker: MockerFixture,
11271270
):

0 commit comments

Comments
 (0)