Skip to content

fix: don't crash reading a non-JSON body from the IDP (FLYTE-SDK-60) - #1453

Open
EngHabu wants to merge 1 commit into
mainfrom
fix/sentry-60-token-json
Open

fix: don't crash reading a non-JSON body from the IDP (FLYTE-SDK-60)#1453
EngHabu wants to merge 1 commit into
mainfrom
fix/sentry-60-token-json

Conversation

@EngHabu

@EngHabu EngHabu commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The bug

get_token reads the failure body as JSON before raising the error it already has ready:

if not response.is_success:
    j = response.json()                    # <-- dies here
    if "error" in j:
        ...
    raise AuthenticationError("Status Code ({}) received from IDP: {}".format(...))

The OAuth endpoints are specified to answer in JSON, but what actually reaches the SDK is whatever a deployment puts in the way. The FLYTE-SDK-60 event is a proxy answering Internal Server Error in front of a broken IDP, so .json() raises and the AuthenticationError on the next line never happens.

There are four unguarded .json() calls between get_token and get_device_code, and two of them are on error paths — including one interpolated into the message of the exception being constructed:

raise AuthenticationError(f"... Status Code {resp.status_code} Reason {resp.json()}")

Why it reaches Sentry

json.JSONDecodeError is not a classified auth failure, so it survives the wrap chain unrecognized:

JSONDecodeError: Expecting value: line 1 column 1 (char 0)
  -> RuntimeError: SelectCluster failed for operation=1: Expecting value: line 1 column 1 (char 0)
    -> RuntimeSystemError: Upload failed for /tmp/…tar.gz (org='…'): Internal Server Error

_sentry._is_user_error already walks the cause chain looking for AuthenticationError behind exactly this SelectCluster wrap — the comment there says so. It just never finds one, because the classification was lost at the first frame. So the user is told Expecting value: line 1 column 1 (char 0) and we get a crash report for someone else's 502.

The fix

Parse defensively and let each branch report what came back. No branch changes what it raises — they just survive reading the body first.

  • _json_object_or_none returns None instead of raising, and only accepts a JSON object: a body parsing to the bare string "error" would satisfy "error" in j by substring, which isn't the membership test the caller means.
  • _body_snippet names the content-type and a truncated body, so "the endpoint isn't the IDP" is legible without dumping a 5KB HTML page into a log line.
  • The 2xx path also rejects a JSON object with no access_token — same branch, and KeyError: 'access_token' is the same unactionable crash report as the decode error.

Verification

The test module imports new symbols, so stashing src/ would fail at collection and prove nothing. Verified with a standalone repro instead:

=========== ON MAIN ===========                            =========== WITH FIX ===========
get_token  500 + HTML body: JSONDecodeError  -> LEAKS      get_token  500 + HTML body: AuthenticationError -> classified
get_token  200 + HTML body: JSONDecodeError  -> LEAKS      get_token  200 + HTML body: AuthenticationError -> classified
get_device 502 + HTML body: JSONDecodeError  -> LEAKS      get_device 502 + HTML body: AuthenticationError -> classified
get_device 200 + HTML body: JSONDecodeError  -> LEAKS      get_device 200 + HTML body: AuthenticationError -> classified

And through the real wrap chain from the Sentry event, confirming the report actually stops:

ON MAIN    inner=JSONDecodeError       _is_user_error(RuntimeSystemError) = False  -> REPORTED to Sentry
WITH FIX   inner=AuthenticationError   _is_user_error(RuntimeSystemError) = True   -> filtered

Tests

20 cases in tests/flyte/remote/test_token_client.py — the four non-JSON bodies, the JSON-but-not-an-object cases, snippet truncation, and the happy paths that must keep working (access token returned, absent refresh token, and the authorization_pending/slow_down branch that keeps the device-code poll loop alive). tests/flyte/remote/ is 595 passed; make check-docstrings, ruff, and mypy clean.

Not in scope

j["expires_in"] on the success path can still KeyError — RFC 6749 makes expires_in RECOMMENDED, not required, so a conforming IDP may omit it. Picking a default there changes refresh timing, which is a judgment call rather than a crash fix, and there's no Sentry evidence for it. Happy to follow up if you want a value chosen.

fixes FLYTE-SDK-60

The OAuth token and device-code endpoints are specified to answer in JSON, but
what reaches the SDK is whatever a deployment puts in the way: a load balancer's
HTML 502 page, a proxy's plain-text "Internal Server Error", an SSO
interstitial. Four `response.json()` calls read those bodies unguarded, and two
of them sit on the *error* paths -- so the SDK crashed with a bare
`json.JSONDecodeError` on its way to raising the well-formed
`AuthenticationError` it already had ready.

Because the decode error is not a classified auth failure, it survives the
SelectCluster/upload wrap unrecognized and lands in Sentry as an SDK bug, with
`Expecting value: line 1 column 1 (char 0)` as the only thing the user is told.

Parse the body defensively and let each branch report what actually came back.

fixes FLYTE-SDK-60

Signed-off-by: Haytham Abuelfutuh <haytham@afutuh.com>
@EngHabu EngHabu added the sentry-fix Fix for an issue surfaced by Sentry label Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sentry-fix Fix for an issue surfaced by Sentry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant