Skip to content

Commit 93649a1

Browse files
Kastier1claude
andauthored
Add request ID tracking to token validation for support correlation (#6821)
* Send X-Request-ID with token validation and surface it on failure Tag every call to /api/v1/authenticate/me with a client-generated X-Request-ID header and quote it in the (now visible) warning/error when validation fails, so intermittent auth failures can be correlated with control-plane logs. Expose the id via get_auth_request_id() for downstream packages (e.g. reflex-enterprise) to include in their own error messages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuGeE8ncgcXTmCTfuP3DE2 * Add news fragment for auth request id feature Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuGeE8ncgcXTmCTfuP3DE2 * Carry request id on token validation exceptions Address review: the process-global last-request-id could misreport under concurrent validations. validate_token now raises TokenValidationError / TokenAccessDeniedError (backward compatible with Exception / ValueError) carrying the request id of their own request, and validate_token_with_retries prefers that over the global. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CuGeE8ncgcXTmCTfuP3DE2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8aaddf commit 93649a1

4 files changed

Lines changed: 128 additions & 14 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Token validation requests now send an `X-Request-ID` header, and failed validations print the ID (e.g. `Unable to validate access token: ... (auth request id: ...)`) so it can be quoted to support to correlate the failure with server-side logs.

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,28 @@ class NotAuthenticatedError(ReflexHostingCliError):
99
"""Raised when the user is not authenticated."""
1010

1111

12+
class TokenValidationError(ReflexHostingCliError):
13+
"""Raised when validating an access token with the control plane fails.
14+
15+
Carries the X-Request-ID sent with the validation request so callers can
16+
surface it for correlation with server-side logs.
17+
"""
18+
19+
def __init__(self, message: str, request_id: str = ""):
20+
"""Initialize the error.
21+
22+
Args:
23+
message: The error message.
24+
request_id: The X-Request-ID header sent with the failed request.
25+
"""
26+
super().__init__(message)
27+
self.request_id = request_id
28+
29+
30+
class TokenAccessDeniedError(TokenValidationError, ValueError):
31+
"""Raised when the control plane rejects the access token."""
32+
33+
1234
class GetAppError(ReflexHostingCliError):
1335
"""Raised when retrieving an app fails."""
1436

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

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
ResponseError,
3333
ScaleAppError,
3434
ScaleParamError,
35+
TokenAccessDeniedError,
36+
TokenValidationError,
3537
)
3638

3739

@@ -389,6 +391,24 @@ def is_reflex_enterprise_installed() -> bool:
389391
return True
390392

391393

394+
_last_auth_request_id: str = ""
395+
396+
397+
def get_auth_request_id() -> str:
398+
"""Get the request id sent with the most recent token validation request.
399+
400+
The id is sent to the control plane as the ``X-Request-ID`` header, so it
401+
can be quoted to support to correlate a failed authentication with the
402+
server-side logs.
403+
404+
Returns:
405+
The request id of the last ``validate_token`` call, or an empty string
406+
if no validation request has been made in this process.
407+
408+
"""
409+
return _last_auth_request_id
410+
411+
392412
def validate_token(token: str) -> dict[str, Any]:
393413
"""Validate the token with the control plane.
394414
@@ -399,12 +419,15 @@ def validate_token(token: str) -> dict[str, Any]:
399419
Information about the user associated with the token.
400420
401421
Raises:
402-
ValueError: if access denied.
403-
Exception: if runs into timeout, failed requests, unexpected errors. These should be tried again.
422+
TokenAccessDeniedError: if access denied.
423+
TokenValidationError: if runs into timeout, failed requests, unexpected errors. These should be tried again.
404424
405425
"""
406426
import httpx
407427

428+
global _last_auth_request_id
429+
request_id = _last_auth_request_id = uuid.uuid4().hex
430+
408431
try:
409432
# Add reflex-enterprise detection flag as query parameter
410433
params = {
@@ -415,24 +438,28 @@ def validate_token(token: str) -> dict[str, Any]:
415438

416439
response = httpx.post(
417440
urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/authenticate/me"),
418-
headers=authorization_header(token),
441+
headers={**authorization_header(token), "X-Request-ID": request_id},
419442
params=params,
420443
timeout=constants.Hosting.TIMEOUT,
421444
)
422445
response.raise_for_status()
423446
return response.json()
424447
except httpx.RequestError as re:
425-
console.debug(f"Request to auth server failed due to {re}")
426-
raise Exception(str(re)) from re
448+
console.debug(
449+
f"Request to auth server failed due to {re} (request id: {request_id})"
450+
)
451+
raise TokenValidationError(str(re), request_id=request_id) from re
427452
except httpx.HTTPError as ex:
428-
console.debug(f"Unable to validate the token due to: {ex}")
429-
raise Exception("server error") from ex
453+
console.debug(
454+
f"Unable to validate the token due to: {ex} (request id: {request_id})"
455+
)
456+
raise TokenValidationError("server error", request_id=request_id) from ex
430457
except ValueError as ve:
431-
console.debug("Access denied")
432-
raise ValueError("access denied") from ve
458+
console.debug(f"Access denied (request id: {request_id})")
459+
raise TokenAccessDeniedError("access denied", request_id=request_id) from ve
433460
except Exception as ex:
434-
console.debug(f"Unexpected error: {ex}")
435-
raise Exception("internal errors") from ex
461+
console.debug(f"Unexpected error: {ex} (request id: {request_id})")
462+
raise TokenValidationError("internal errors", request_id=request_id) from ex
436463

437464

438465
def delete_token_from_config():
@@ -2486,11 +2513,16 @@ def validate_token_with_retries(access_token: str) -> dict[str, Any]:
24862513
with console.status("Validating access token ..."):
24872514
try:
24882515
return validate_token(access_token)
2489-
except ValueError:
2490-
console.error("Access denied")
2516+
except ValueError as ex:
2517+
# getattr: mocks/foreign ValueErrors don't carry a request id.
2518+
request_id = getattr(ex, "request_id", "") or get_auth_request_id()
2519+
console.error(f"Access denied (auth request id: {request_id})")
24912520
delete_token_from_config()
24922521
except Exception as ex:
2493-
console.debug(f"Unable to validate token due to: {ex}")
2522+
request_id = getattr(ex, "request_id", "") or get_auth_request_id()
2523+
console.warn(
2524+
f"Unable to validate access token: {ex} (auth request id: {request_id})"
2525+
)
24942526
return {}
24952527

24962528

tests/units/reflex_cli/utils/test_hosting.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import httpx
88
import pytest
99
from pytest_mock import MockerFixture, MockFixture
10+
from reflex_cli.utils.exceptions import TokenValidationError
1011
from reflex_cli.utils.hosting import (
1112
AuthenticatedClient,
1213
ScaleParams,
@@ -18,6 +19,7 @@
1819
delete_token_from_config,
1920
gcp_deploy_available,
2021
get_app_history,
22+
get_auth_request_id,
2123
get_authenticated_client,
2224
get_existing_access_token,
2325
get_gcp_provider_status,
@@ -34,6 +36,8 @@
3436
set_app_provider,
3537
submit_security_review,
3638
update_deployment_description,
39+
validate_token,
40+
validate_token_with_retries,
3741
)
3842

3943
_CLIENT = AuthenticatedClient(token="fake-token", validated_data={})
@@ -592,3 +596,58 @@ def test_get_app_history_includes_description_and_can_rollback(mocker: MockerFix
592596
assert history[0]["can rollback"] is True
593597
assert history[1]["description"] == ""
594598
assert history[1]["can rollback"] is False
599+
600+
601+
def test_validate_token_sends_request_id_header(mocker: MockerFixture):
602+
"""Each validation request carries a fresh X-Request-ID header."""
603+
mock_post = mocker.patch(
604+
"httpx.post", return_value=_ok(mocker, {"tier": "enterprise"})
605+
)
606+
607+
assert validate_token("some-token") == {"tier": "enterprise"}
608+
609+
headers = mock_post.call_args.kwargs["headers"]
610+
assert headers["X-API-TOKEN"] == "some-token"
611+
assert headers["X-Request-ID"] == get_auth_request_id() != ""
612+
613+
first_request_id = get_auth_request_id()
614+
validate_token("some-token")
615+
assert get_auth_request_id() != first_request_id
616+
617+
618+
def test_validate_token_with_retries_warns_with_request_id(mocker: MockerFixture):
619+
"""A failed validation surfaces the request id for support correlation."""
620+
mocker.patch("httpx.post", return_value=_error(mocker, 500, "boom"))
621+
mock_warn = mocker.patch("reflex_cli.utils.hosting.console.warn")
622+
623+
assert validate_token_with_retries("some-token") == {}
624+
625+
request_id = get_auth_request_id()
626+
assert request_id
627+
assert request_id in mock_warn.call_args.args[0]
628+
629+
630+
def test_validate_token_with_retries_access_denied_reports_request_id(
631+
mocker: MockerFixture,
632+
):
633+
"""The access denied error message includes the auth request id."""
634+
response = mocker.Mock()
635+
response.raise_for_status.return_value = None
636+
response.json.side_effect = ValueError("bad json")
637+
mocker.patch("httpx.post", return_value=response)
638+
mock_error = mocker.patch("reflex_cli.utils.hosting.console.error")
639+
mocker.patch("reflex_cli.utils.hosting.delete_token_from_config")
640+
641+
assert validate_token_with_retries("some-token") == {}
642+
643+
assert get_auth_request_id() in mock_error.call_args.args[0]
644+
645+
646+
def test_validate_token_failure_carries_request_id_on_exception(mocker: MockerFixture):
647+
"""Validation errors carry the request id of their own request."""
648+
mocker.patch("httpx.post", return_value=_error(mocker, 500, "boom"))
649+
650+
with pytest.raises(TokenValidationError) as exc_info:
651+
validate_token("some-token")
652+
653+
assert exc_info.value.request_id == get_auth_request_id() != ""

0 commit comments

Comments
 (0)