Repository navigation
Conversation
…PIRED An analytics query with an invalid or expired user token returned a 500 EXECUTION_ERROR. The SQL warehouse connector wrapped the SDK ApiError in ExecutionError.statementFailed(), which drops the HTTP status, so normalizeIdentityError() could no longer recognize the 401 and never produced IdentityExpiredError. - Classify the raw SDK error with normalizeIdentityError() in _rethrowStatementError before wrapping. Inside a caller scope a 401 now becomes IdentityExpiredError (401, IDENTITY_EXPIRED). The service-principal lane is unchanged. - The ARROW_STREAM route returned a hard-coded 500. It now uses the AppKitError's own statusCode and forwards IDENTITY_EXPIRED as errorCode, matching the SSE error payload. Databricks answers "Invalid access token" with a 403; the next commit recognizes that as identity expiry. This change passes the raw error to the shared classifier, so that fix applies here too. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Fabian Jakobs <fabian.jakobs@databricks.com>
Databricks rejects expired, revoked, and malformed PATs with
403 {"error_code":403,"message":"Invalid access token."} and expired OAuth
tokens with a plain-text 403 "Invalid Token", not 401. isUnauthorized only
matched 401, so these never became IdentityExpiredError / IDENTITY_EXPIRED
and surfaced as a generic 403 Forbidden instead of a reauthenticate signal.
Also accept a 403 whose message or (sdk-core) HTTP body reports an invalid
token. Permission-denied 403s and other failures keep their behavior.
Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Fabian Jakobs <fabian.jakobs@databricks.com>
Contributor
📦 Bundle size reportCompared against
|
| dist | raw | gzip |
|---|---|---|
| JS (runtime) | 1.3 MB (+1005 B) | 460 KB (+354 B) |
| Type declarations | 465 KB | 169 KB (+1 B) |
| Source maps | 2.5 MB (+2.0 KB) | 860 KB (+759 B) |
| Other | 11 KB | 3.7 KB |
| Total | 4.2 MB (+3.0 KB) | 1.5 MB (+1.1 KB) |
Per-entry composition (own code — deps external (as shipped))
| Entry | Initial (gz) | Lazy (gz) | Total (gz) | node_modules (min) | Own code (min) |
|---|---|---|---|---|---|
. |
106 KB (+125 B) | 2.5 KB | 109 KB (+125 B) | external | 345 KB (+322 B) |
./beta |
103 KB (+122 B) | 485 B (+3 B) | 104 KB (+125 B) | external | 310 KB (+269 B) |
./testing |
45 KB (+90 B) | 32 KB (-1 B) | 77 KB (+89 B) | external | 224 KB (+242 B) |
./tsdown |
520 B | 0 B | 520 B | external | 813 B |
./type-generator |
26 KB | 0 B | 26 KB | external | 74 KB |
Chunks:
| Entry | Chunk | Load | Size (gz) |
|---|---|---|---|
. |
index.js |
initial | 102 KB |
. |
utils.js |
initial | 4.6 KB |
. |
remote-tunnel-manager.js |
lazy | 2.5 KB |
./beta |
beta.js |
initial | 83 KB |
./beta |
modular.js |
initial | 6.0 KB |
./beta |
stream-manager.js |
initial | 5.9 KB |
./beta |
service-context.js |
initial | 4.3 KB |
./beta |
databricks.js |
initial | 3.3 KB |
./beta |
client.js |
initial | 647 B |
./beta |
index.js |
initial | 20 B |
./beta |
supervisor-api.js |
lazy | 192 B |
./beta |
databricks.js |
lazy | 178 B |
./beta |
index.js |
lazy | 115 B |
./testing |
manifest.js |
initial | 32 KB |
./testing |
index.js |
initial | 10 KB |
./testing |
wide-event-emitter.js |
initial | 2.9 KB |
./testing |
index.js |
lazy | 28 KB |
./testing |
remote-tunnel-manager.js |
lazy | 2.5 KB |
./testing |
utils.js |
lazy | 1.8 KB |
./tsdown |
index.js |
initial | 520 B |
./type-generator |
index.js |
initial | 26 KB |
@databricks/appkit-ui
npm tarball (packed): 397 KB — gzipped download (dist + bin; excludes release-only docs/NOTICE).
| dist | raw | gzip |
|---|---|---|
| JS (runtime) | 429 KB | 147 KB |
| Type declarations | 267 KB | 98 KB |
| Source maps | 853 KB | 286 KB |
| CSS | 16 KB | 3.2 KB |
| Total | 1.5 MB | 534 KB |
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
| Entry | Initial (gz) | Lazy (gz) | Total (gz) | node_modules (min) | Own code (min) |
|---|---|---|---|---|---|
./js |
5.3 KB | 49 KB | 55 KB | 208 KB | 14 KB |
./js/beta |
2.1 KB | 0 B | 2.1 KB | 0 B | 5.0 KB |
./react |
433 KB | 49 KB | 481 KB | 1.3 MB | 177 KB |
./react/beta |
4.9 KB | 0 B | 4.9 KB | 0 B | 12 KB |
Chunks:
| Entry | Chunk | Load | Size (gz) |
|---|---|---|---|
./js |
index.js |
initial | 5.2 KB |
./js |
chunk |
initial | 120 B |
./js |
apache-arrow |
lazy | 49 KB |
./js/beta |
beta.js |
initial | 2.1 KB |
./react |
index.js |
initial | 431 KB |
./react |
tslib |
initial | 2.1 KB |
./react |
apache-arrow |
lazy | 49 KB |
./react/beta |
beta.js |
initial | 4.9 KB |
Contributor
🤖 AppKit PR bot🔬 Run evalsStart an eval for this PR from the evals-monitor app: Go to Evals Monitor → 📦 Try this PR's app templateScaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh run download 42243086321 -R databricks/appkit -n appkit-template-0.90.0-pr.2be54de-fjakobs-la-2048-analytics-invalid-token-500-714 -D appkit-pr-714 \
&& unzip -o "appkit-pr-714/appkit-template-0.90.0-pr.2be54de-fjakobs-la-2048-analytics-invalid-token-500-714.zip" -d "appkit-pr-714" \
&& databricks apps init --template "appkit-pr-714"The template pins |
MarioCadenas
approved these changes
Nov 6, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When a forwarded user token is invalid, expired or revoked, AppKit is supposed to raise
IdentityExpiredError(HTTP 401, codeIDENTITY_EXPIRED). That is the documented "reauthenticateand retry" signal. Two bugs kept it from firing:
500 EXECUTION_ERROR. An on-behalf-of-user analytics query with a rejectedtoken failed as a generic execution failure on every surface: the
asUser(req).analytics.query()API, the
ARROW_STREAMroute and the JSON/SSE route.get
403 {"error_code":403,"message":"Invalid access token."}. Expired OAuth tokens get aplain-text
403 Invalid Token. Only a 401 was recognized, so these surfaced as a generic403 Forbidden(or as the 500 from bug 1) and read like a permissions problem.Root cause
SQLWarehouseConnector._rethrowStatementErrorwrapped the SDKApiErrorinExecutionError.statementFailed(message, code). The wrapper keeps no HTTP status and nocause,so
normalizeIdentityError()could no longer see the 401 by the time it ran.ARROW_STREAMerror branch hard-codedres.status(500).isUnauthorized()(used bynormalizeIdentityError()) matched status 401 only.Fix
connectors/sql-warehouse/client.ts: classify the raw SDK error withnormalizeIdentityError()before wrapping. Inside a caller scope a rejected token becomes
IdentityExpiredError. Outside one(service-principal execution) the behavior is unchanged. No upstream error is retained on the new
error.
plugins/analytics/analytics.ts: theARROW_STREAMerror response now uses theAppKitError'sown
statusCodeand forwardsIDENTITY_EXPIREDaserrorCode, matching the SSE error payload.As a side effect, other typed errors on this route also get their own status instead of a blanket
500:
ValidationError400,ConnectionError503. Execution and configuration errors stay 500.errors/identity-expired.ts:isUnauthorized()also accepts a 403 whose message or HTTP bodyreports an invalid token (
/\binvalid (access )?token\b/i). It reads the body because the SDKleaves
messageempty for the plain-text OAuth response. Permission-denied 403s and other failureskeep their behavior.
execution-context.mdandagents.mddescribe the 403 case.SSE responses still use HTTP 200, because the stream headers are sent before the error. On that
transport the signal is
errorCode: "IDENTITY_EXPIRED"in the error event, as before.Tests
connectors/tests/sql-warehouse.test.ts:executeStatement→IdentityExpiredErrorunder a caller, with fingerprint kept andno upstream text
ExecutionError(500)plugins/analytics/tests/analytics.test.ts: theARROW_STREAMroute returns 401 witherrorCode: "IDENTITY_EXPIRED".errors/tests/http-status.test.ts, using the exact response bodies seen from the workspace:message),and the shared raw-request
ApiError, both directly and as acausePERMISSION_DENIED403 and a 400Invalid Tokenare notcontext/tests/identity-lifecycle.test.ts: a 403 invalid-token error converts like a 401, and apermission-denied 403 is preserved.
Without the source changes, the new regression tests fail.
End-to-end verification
I ran against a real Databricks workspace using small AppKit apps in development mode
(
server+analytics, andserver+fileswith an on-behalf-of-user volume). Test tokens weresent as
x-forwarded-access-token:asUser().query()ARROW_STREAMerrorCodeasUser().run()Invalid access token.IDENTITY_EXPIREDUNKNOWN→IDENTITY_EXPIREDIDENTITY_EXPIREDInvalid access token.IDENTITY_EXPIREDInvalid access token.IDENTITY_EXPIREDIDENTITY_EXPIREDIDENTITY_EXPIREDInvalid Token(plain text)IDENTITY_EXPIREDIDENTITY_EXPIREDIDENTITY_EXPIREDIDENTITY_EXPIREDUNKNOWN→IDENTITY_EXPIREDresult(unchanged)Invalid TokenIn each cell,
a → bmeans I measured both before and after the change; a single value means I measured it after only. Theexpired OAuth row covers the case where the SDK error has an empty
messageand only the responsebody says the token is invalid. That is Databricks' response for an expired OAuth token, which is
the token type a deployed app forwards. I tested it with a workspace OAuth token rather than one
forwarded by a deployed app.
The token never appears in the server logs.
Checks:
pnpm build,pnpm docs:build,pnpm check:fixandpnpm -r typecheckpass locally.pnpm testpasses locally (309 files, 5933 tests). I ran it with a raised per-test timeout: thelocal machine was heavily loaded, and a few tests that boot a test HTTP server exceeded the default
5s. No assertion failed.
Not in this PR
surface
IDENTITY_EXPIRED.401 {"error":"Unauthorized"},without the
IDENTITY_EXPIREDcode.CAN_USEon the warehouse) is deliberatelynot treated as identity expiry. From analytics it still becomes a 500
EXECUTION_ERROR, as before.I didn't reproduce this case.
This pull request and its description were written by Isaac.