Skip to content

feat(appkit): allowlist DBSQL error codes whose messages are safe to forward - #399

Closed
jamesbroadhead wants to merge 22 commits into
mainfrom
stack/arrow-5-error-allowlist
Closed

feat(appkit): allowlist DBSQL error codes whose messages are safe to forward#399
jamesbroadhead wants to merge 22 commits into
mainfrom
stack/arrow-5-error-allowlist

Conversation

@jamesbroadhead

Copy link
Copy Markdown
Contributor

Summary

Follow-up to the error-sanitization work in #329. Restores user-facing SQL error messages (e.g. `Table 'foo' not found`, `Syntax error near ','`) without re-introducing the CWE-209 leak from raw control-plane wording.

#329 collapses every `ExecutionError.statementFailed` to a generic "Query execution failed" client message. That was the right safe-default for the sanitization pass, but it loses real user value — anyone with a typo in their SQL now debugs blind, because the error is the user's own SQL artifact and was authored by DBR for them to read.

This PR splits the two error sources by `ServiceErrorCode`:

  1. DBR (data plane) — populates `errorDisplayMessage` for user-authored SQL errors. Source of truth: `sqlgateway/scheduler/src/main/scala/DriverRequests.scala:181-208` — `ErrorInfo.withMessage(command.errorDisplayMessage).withErrorReturnedFromDataPlane(true)`. Codes: `BAD_REQUEST`, `NOT_FOUND`, `ALREADY_EXISTS`, `DEADLINE_EXCEEDED`, `CANCELLED`, `UNAUTHENTICATED`. These messages are the user's own SQL — passthrough is correct.

  2. Control plane (proxy / scheduler / metaservice) — wraps internal RPC failures, scheduler exceptions, capacity rejections. Source: `sqlgateway/scheduler/src/main/scala/utils/CommandUtils.scala`, `exceptions.scala`. Codes: `INTERNAL_ERROR`, `IO_ERROR`, `UNKNOWN`, `RESOURCE_EXHAUSTED`, `SERVICE_UNDER_MAINTENANCE`, `TEMPORARILY_UNAVAILABLE`, `WORKSPACE_TEMPORARILY_UNAVAILABLE`, `ABORTED`. These can carry correlation IDs, RPC paths, stack traces — must not pass through.

Design

  • New module `packages/appkit/src/errors/dbsql-error-allowlist.ts` exports `isSqlErrorPassthrough(errorCode)` — a `ReadonlySet`-backed predicate over `ServiceErrorCode`.
  • `ExecutionError.statementFailed` consults the allowlist before defaulting `clientMessage`. Allowlisted codes get the verbatim warehouse text; anything else (including future SDK additions) collapses to the generic "Query execution failed".
  • An explicit `clientMessage` argument always wins — the allowlist is a default, not a ceiling.
  • Server-side `.message` always preserves the raw upstream text for `logger.error` capture; sanitization is purely at the wire boundary.

Stack

Stacks on #329. Targets `stack/arrow-3-inline-arrow-fix` (not `main`) so it merges as the next layer once the parent lands.

Test plan

  • `describe("statementFailed clientMessage passthrough")` — 13 new cases covering each allowlisted code, key denied codes, default-deny on unknown / undefined / lowercased input, explicit clientMessage override, and raw-message preservation for log debugging.
  • `dbsql-error-allowlist.test.ts` — focused tests on the predicate itself (allowlist members, explicit denylist members, undefined / empty / unknown / lowercase).
  • Full `appkit` suite: 2,129 tests pass (+31 new).
  • `tsc --noEmit` clean; `biome check` clean.

This pull request and its description were written by Isaac.

Renames the client-side analytics format model from "JSON"/"ARROW" to
"JSON_ARRAY"/"ARROW_STREAM" to match the Statement Execution API enum
verbatim — no more local-name to API-name translation.
Pure mechanical rename. No behavior change. Internal type values only;
the lowercase user-facing values passed to useChartData ("json", "arrow",
"auto") are unchanged.
Carved out of #256 (#327 is layer 1, this is layer 2). The actual
inline-Arrow-IPC + warehouse-fallback fix sits on top of this in layer 3.
Note: this is a breaking change for any direct consumer of
useAnalyticsQuery passing explicit format: "JSON" or "ARROW" — they will
need to update to "JSON_ARRAY" / "ARROW_STREAM". Consumers using
useChartData (lowercase "json"/"arrow"/"auto") are unaffected.
Co-authored-by: Isaac
Widen AnalyticsFormat to also include the pre-rename "JSON" and "ARROW"
spellings, both marked @deprecated with a JSDoc note describing the
removal condition (no consumer on appkit/appkit-ui < 0.33.0). Add a
normalizeAnalyticsFormat helper and call it at the analytics route
handler entry point so all downstream code (cache key, format
branching, formatParameters) continues to operate on the canonical
"JSON_ARRAY" | "ARROW_STREAM" values.
InferResultByFormat is widened to also match "ARROW" so callers
passing the legacy spelling still get TypedArrowTable<...> inferred.
This lifts the breaking-change carve-out from the rename, so callers
of useAnalyticsQuery({ format: "JSON" | "ARROW" }) keep working with
only an IDE deprecation hint.
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
Serverless warehouses return ARROW_STREAM + INLINE results as base64 Arrow
IPC in result.attachment rather than result.data_array. The previous code
path discarded inline data for any ARROW_STREAM response (designed for
EXTERNAL_LINKS), so these warehouses silently returned empty results.
This commit makes the analytics plugin work across classic and serverless
warehouses by handling both dispositions for ARROW_STREAM, decoding inline
Arrow IPC attachments server-side, and falling back to JSON_ARRAY when a
warehouse rejects ARROW_STREAM + INLINE.
Changes
- Inline Arrow IPC decoding (new arrow-schema.ts) via apache-arrow's
tableFromIPC, producing the same row-object shape as JSON_ARRAY
regardless of warehouse backend. apache-arrow@21.1.0 added as a server
dep.
- Format fallback: ARROW_STREAM + INLINE requests automatically fall back
to JSON_ARRAY if a classic warehouse rejects them. Explicit format
requests are respected without fallback.
- Zod-validated SSE wire protocol for /api/analytics/query (shared schema
between server and client; malformed payloads surface a clear error
instead of silent undefined).
- Default remains JSON_ARRAY for compatibility.
Stack: layer 3 of 3 carved from #256.
- #327 — coverage backfill (layer 1)
- #328 — AnalyticsFormat rename to API enum names (layer 2)
- (this PR) — the actual fix
Fixes#242
Co-authored-by: Isaac
Six issues surfaced by GPT 5.4 xhigh + Gemini 3.1 Pro parallel review
followed by an adversarial debate round (reviewer: GPT, critic: Gemini,
meta: Claude Opus).
1. Raise SSE event-size cap from 8 MiB to 12 MiB on both server
(streamDefaults.maxEventSize) and client (connectSSE.maxBufferSize).
The inline Arrow attachment cap (MAX_INLINE_ATTACHMENT_BYTES) stays
at 8 MiB *decoded*; base64 encoding + JSON + SSE framing inflate that
to ~10.6 MiB on the wire, so 12 MiB leaves enough headroom for legal
8-MiB-decoded payloads to traverse the buffer.
2. Empty `data_array: []` is truthy, so zero-row ARROW_STREAM responses
skipped empty-table synthesis and fell through to the JSON row
transform — callers requesting Arrow got [] JSON rows. Length-check
explicitly.
3. The arrow-fix commit dropped lowercase legacy "json" / "arrow" from
DataFormat / resolveFormat(), silently breaking existing useChartData
callers passing those spellings. Restore them as @deprecated aliases
on the DataFormat union; resolveFormat() normalizes them to the
canonical "JSON_ARRAY" / "ARROW_STREAM" return values.
4. The JSON_ARRAY -> ARROW_STREAM retry in DESCRIBE QUERY only fired on
thrown exceptions. Some warehouses signal the rejection as
`status.state === "FAILED"` instead. Extract the rejection-matcher
helper and retry on both paths before degrading the typegen result
to `unknown`.
5. analytics.test.ts:946 asserted `format: "JSON"` returns 400, but the
route now accepts "JSON" as a legacy alias (normalized to
JSON_ARRAY). Use a truly unsupported value ("CSV") so the test still
exercises the malformed-format path.
6. Restore `zod: 4.3.6` to @databricks/appkit dependencies. main has it;
the rebase conflict-resolution accepted the branch's older deps list
which lacked it. appkit imports `zod` directly from several files
(analytics.ts, agent tools, tests).
Co-authored-by: Isaac
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
The original commit added zod@3.23.8 to shared for the new SSE wire
protocol schema. With zod restored on appkit at 4.3.6 (matching main),
the workspace now had two different zod majors resolving in different
packages — a latent peer-dep / type-incompatibility foot-gun even
though the schema itself was already cross-major-compatible.
Bump shared's zod to 4.3.6 so the whole workspace lands on one major.
The schema's two-arg `z.record(z.string(), z.unknown())` form is the
zod 4 spelling, so no functional change is needed; drop the now-stale
"keeps it valid under either major" comment.
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
# Conflicts:
#	packages/appkit-ui/src/react/hooks/types.ts
#	packages/appkit-ui/src/react/hooks/use-chart-data.ts
#	packages/appkit/src/plugins/analytics/analytics.ts
#	packages/appkit/src/plugins/analytics/types.ts
Restoring zod@4.3.6 to appkit and bumping shared's zod from 3.23.8 to
4.3.6 left the lockfile out of sync with package.json, breaking CI's
pnpm install --frozen-lockfile step on every job. Regenerate the
lockfile so both specifier entries match the manifests.
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
The merge with main left two separate import statements for "./types" —
one for the type-only specifiers and a duplicate value import of
normalizeAnalyticsFormat. Biome rejected this as both an
organize-imports failure and a noRedeclare error. Merge them into a
single mixed type/value import.
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
The merge resolution in client.ts dropped the logger.error call from
the executeStatement catch block — main has it, our pre-merge branch
had it, the resolved version lost it. Without that line the
"error log redaction" tests fail because the connector no longer
surfaces the failure message to the log spy.
Restore the call. Test plan: the two sql-warehouse.test.ts redaction
tests pass locally; behavior matches the comment "executeStatement's
catch ... is the single point that logs (gated on isAborted)".
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
…e SSE message
Address Mario's design feedback: SSE is for short control messages, not
bulk binary. Inline Arrow IPC payloads from serverless warehouses no
longer ride the SSE channel as base64; they are stashed server-side and
fetched out-of-band through the existing /arrow-result/:jobId endpoint
with the canonical application/vnd.apache.arrow.stream content-type.
Wire protocol
- Discriminated union shrinks from three variants to two: the
arrow_inline message type is gone. Both INLINE and EXTERNAL_LINKS
ARROW_STREAM responses now flow as a single `arrow` message whose
statement_id discriminates dispatch: warehouse-issued ids hit the
warehouse path, synthetic "inline-<uuid>" ids hit the stash. The
client sees one path.
Server
- New InlineArrowStash: TTL'd (10 min), bounded-memory (256 MiB),
drain-on-read, per-user-keyed map of decoded Arrow IPC bytes. Stash
key is the request's user id (or "global" for SP contexts) and is
symmetric between put and take.
- AnalyticsPlugin holds one stash instance and uses it in two places:
- _executeWithFormatFallback decodes result.attachment once, puts the
bytes in the stash, and emits an arrow message with the synthetic
id. Bulk bytes never traverse SSE.
- _handleArrowRoute prefix-dispatches on the jobId: "inline-" drains
the stash and serves with application/vnd.apache.arrow.stream + a
no-store cache header; other ids fall through to the existing
warehouse-fetch path unchanged.
- Connector's MAX_INLINE_ATTACHMENT_BYTES raised from 8 MiB to 25 MiB
(the Databricks API hard cap on INLINE) since the SSE event-size
budget no longer constrains it.
Client
- useAnalyticsQuery loses the arrow_inline branch and the local base64
decoder. Both inline and external-links responses fetch through
/api/analytics/arrow-result/:id; the prefix branch lives server-side.
- The dead client-side MAX_INLINE_ATTACHMENT_BYTES guard goes away.
SSE buffers
- streamDefaults.maxEventSize: 12 MiB -> 1 MiB
- connectSSE.maxBufferSize: 12 MiB -> 1 MiB
SSE now carries only short JSON control messages (result rows, arrow
envelope with statement id, error frames). Multi-MiB caps are no longer
needed and would mask buffer regressions.
Tests
- New InlineArrowStash unit tests (TTL eviction, max-bytes LRU, drain-
on-read, per-user scoping).
- Reworked the route's "emits arrow_inline" test into a stash + arrow-
message assertion: the SSE payload must not contain the base64 bytes
or the arrow_inline type literal, and the decoded bytes must be in
the stash keyed by the same synthetic id.
- New /arrow-result tests cover the inline path: success drain, 410 on
unknown id, 410 on user mismatch.
- Client tests rewritten to assert both warehouse and inline-prefixed
ids fetch through the same /arrow-result URL with no local decoding.
- Shared schema tests assert the retired arrow_inline type no longer
parses.
- The /arrow-result content-type for warehouse hits stays application/
octet-stream (no behavior change there).
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
Four findings surfaced by the GPT pass on the reworked PR:
1. ARROW_STREAM cache replay returned drained inline-* ids (HIGH).
The previous code capped the cache TTL at 10 min for ARROW_STREAM,
which made sense for EXTERNAL_LINKS pre-signed URLs that expire in
~15 min but is broken for inline ids: the stash drains on the first
/arrow-result fetch, so any cache hit replays an id whose bytes are
gone and reliably 410s. Bypass cache entirely for ARROW_STREAM (TTL
= 0); JSON_ARRAY responses still cache normally.
2. Stash evict-on-fit invalidated already-issued ids (MEDIUM).
The earlier `evictUntilFits` dropped the oldest entries when a new
payload would push total bytes past `maxBytes`, but those oldest
entries had ids that were already in flight to clients. Replace
eviction with rejection: `put()` now returns `string | null` and the
caller falls back to EXTERNAL_LINKS when the stash is full. Every id
we hand out stays valid until naturally drained or expired.
3. Aborted stream still decoded + stashed (MEDIUM).
If the client cancels the SSE between query completion and stash
write, we still decoded the base64 attachment and held the bytes
until TTL eviction. Re-check `signal.aborted` before decode/put so
canceled streams exit cleanly.
4. Empty result message wrote `undefined` to the hook's state (LOW).
The wire schema makes `data` optional; an empty result set may omit
it. Normalize the missing case to `[]` so consumers can rely on
`data` being either `null` (no message yet) or a value of the
inferred result type.
Also documents the process-local-memory constraint on the stash in its
docstring: a `GET /arrow-result/inline-*` that lands on a different
replica than the original SSE request will 410. Multi-replica
deployments need sticky sessions or a shared external store, neither in
scope for this PR.
Tests:
- `inline-arrow-stash`: replaced the eviction test with a rejection
test that asserts `put()` returns null when the stash is full and
that previously-issued ids remain takeable.
- `useAnalyticsQuery`: new test asserts an empty result message
normalizes to [].
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
- agents.ts had two unused imports that biome's noUnusedImports rule
flags as errors in CI. Drop them; behavior unchanged.
- inline-arrow-stash.test.ts: introduce a mustPut() helper that asserts
the non-null contract for successful puts, so the new
`put(): string | null` return type does not poison every downstream
take() call with a string-vs-string|null TS error.
- Minor formatter touch-ups picked up by biome --write.
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
Resolves conflict in
packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts
by combining the PR's arrow-IPC SSE message tests with the
useStableParams refetch tests from #321 (now on main). The merged
file uses a single connectSSE spy that both captures the onMessage
handler (for the arrow tests) and counts invocations (for the
stable-params tests). All 10 tests pass.
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
When the inline Arrow stash refuses a new entry (put returns null),
the route must retry the statement with EXTERNAL_LINKS instead of
emitting a useless inline- id. The stash itself was unit-tested
already; this adds the integration test through the /query route.
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
…arehouses
Some serverless warehouse variants only support ARROW_STREAM for the
INLINE disposition — JSON_ARRAY + INLINE is rejected with 'Inline
disposition only supports ARROW_STREAM format.' Before this change,
every default useAnalyticsQuery call against such a warehouse failed.
The plugin now classifies inline rejections into 'needs-arrow' vs
'needs-json' signals. For a JSON_ARRAY caller hitting needs-arrow, the
plugin retries as ARROW_STREAM + INLINE and decodes the Arrow IPC
attachment back to plain row objects server-side, keeping the caller's
JSON_ARRAY contract intact (scalar values stringified to match the
warehouse's native JSON_ARRAY shape).
The existing ARROW_STREAM + INLINE → EXTERNAL_LINKS path now uses the
same classifier with the 'needs-json' signal. Matching is
case-insensitive and handles the real warehouse error wordings rather
than the case-sensitive 'INLINE' + 'ARROW_STREAM' substring pair the
old heuristic required, which never matched the actual wire errors.
Verified live against three e2-dogfood warehouses: one that refuses
JSON_ARRAY + INLINE, one other serverless, and one classic — all three
now produce identical JSON row output for the same SELECT.
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
Addresses Mario's v3 review on #329 — 1 critical + 3 high findings:
- 🔴 critical: stash-full double-execution + divergent result. The
ARROW_STREAM INLINE fallback used to discard the already-decoded
bytes when the stash was at cap and re-run the same statement on
EXTERNAL_LINKS — that path billed the warehouse twice and could
return a divergent result for non-deterministic SQL. The stash now
has a second pool ("overflow") sized at `maxOverflowBytes` (default
same as `maxBytes`). When the regular pool is full, decoded bytes
spill into overflow rather than being thrown away. Only when both
pools are at cap does the route surface `INLINE_ARROW_STASH_EXHAUSTED`
— single execution, never silent double-billing. Memory is bounded
above by `maxBytes + maxOverflowBytes`.
- 🟠 high: warehouse / SDK error text reflected verbatim to clients
(CWE-209). Added `clientMessage` to AppKitError with a sanitized
default per subclass, and a stable `errorCode` field on the SSE
error payload. Stream-manager and the /arrow-result 410/404
responses now emit `clientMessage` (sanitized) over the wire; raw
upstream wording stays in server logs only. UI can branch on
`errorCode` (e.g. `INLINE_ARROW_STASH_EXHAUSTED`) instead of
substring-matching the human string.
- 🟠 high: JSON_ARRAY fallback shape divergence + heavy materialization.
`decodeArrowAttachmentToRows` now `JSON.stringify`s nested values
(List / Struct / Map) to match what the warehouse emits natively
under JSON_ARRAY — apache-arrow's typed values expose `toJSON()`,
so the output shape matches. Added a hard 100k-row cap on the
fallback materializer; past the cap surfaces
`RESULT_TOO_LARGE_FOR_JSON_FALLBACK` with guidance to re-issue
with ARROW_STREAM, rather than blocking the Node event loop for
hundreds of ms.
- 🟠 high: client `safeParse` O(rows × keys) Zod validation. Loosened
`AnalyticsResultMessage.data` to `z.array(z.unknown())` — the
per-row shape is already enforced at the source by the typed
`makeResultMessage` builder, so the deep client-side validation
was pure cost. TS-level type narrows back to
`Record<string, unknown>[]` for callers.
Also a medium from the review:
- `JSON.parse` failure in `useAnalyticsQuery` no longer strands the
hook in `loading=true` — the outer catch now clears loading and
surfaces a user-facing error.
Co-authored-by: Isaac
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
Second iteration on #329 after running Xavier multi-model review against
4afce1f. Eleven findings (1 critical, 4 high, 6 medium), all addressed:
🔴 critical — `decodeArrowAttachmentToRows` regression: bare
`JSON.stringify` on Arrow nested values throws on `bigint` (the spec
doesn't serialize it) and produces broken shapes for `Uint8Array` /
`Date`. Added `nestedJsonReplacer` that stringifies bigint, base64s
`Uint8Array`, and ISO-strings `Date`. Top-level `Date` / `Uint8Array`
columns get the same treatment in their own branches so we never
reach the nested fallback for them.
🟠 high — single-payload throw threshold was `maxBytes +
maxOverflowBytes`, but a payload can only land in *one* pool (pools
do not split). Fixed to `Math.max(maxBytes, maxOverflowBytes)`.
🟠 high — overflow pool inherited the regular 10-min TTL, which kept
transient bytes around for the full duration of the regular pool's
lifetime and amplified cross-user memory pressure in multi-tenant
deployments. Split into a separate `overflowTtlMs` (default 30s) —
overflow exists only to bridge the immediate `/arrow-result` fetch.
🟠 high — overflow defaulted to the same size as `maxBytes`, doubling
the worst-case memory headroom. Dropped default to `maxBytes / 4`;
overflow only needs to absorb transient spillover, not hold long-term
state. Combined with the shorter TTL, the practical memory delta vs
pre-overflow code is small.
🟠 high — JSON_ARRAY fallback row cap ignored cell size (10 rows × 10MB
cells = OOM under the 100k-row cap). Added a 64MB byte cap on the
decoded attachment, checked before `tableFromIPC` so we never even
allocate the table for an oversize payload.
🟢 medium — `gc()` ran O(N) on every `put`. Throttled to
`gcMinIntervalMs` (default 5s). Tests that drive the clock past TTL
on a sub-throttle scale opt out via `gcMinIntervalMs: 0`.
🟢 medium — `table.getChild(name)` was being called for every cell,
walking the schema fields on every lookup. Hoisted into a
`[name, vector]` array resolved once before the row loop. Real win
at 100k × 50 columns.
🟢 medium — `useAnalyticsQuery` dropped the new structured `errorCode`
from the SSE error payload, so consumers could not actually branch
on the stable identifier the previous commit added. Added
`errorCode: string | null` to `UseAnalyticsQueryResult` and plumbed
it through from `parsed.errorCode`.
🟢 medium — outer `catch` in the SSE handler used to clear loading on a
parse failure but leave the stream open, re-firing the catch on every
subsequent malformed message. Now also calls
`abortController.abort()` so the consumer can retry cleanly.
Validation: 2,097 appkit + 292 appkit-ui tests pass; tsc clean on
appkit / appkit-ui / shared; biome clean on changed files.
Co-authored-by: Isaac
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
Three follow-ups from self-review on the inline-Arrow path:
- **Telemetry**: `InlineArrowStash.put()` now returns `{ id, pool }` so
callers can label outcomes without re-introspecting the stash. The
analytics plugin emits two instruments (lazy-init since `this.telemetry`
isn't bound at construct time):
- `analytics.inline_arrow_stash.put.count{result}` — counter with
label "regular" | "overflow" | "exhausted"
- `analytics.inline_arrow_stash.put.bytes{result}` — histogram of
accepted payload sizes
Operators can now drive capacity-tuning dashboards off `result="overflow"`
spill rate and `result="exhausted"` rejection rate without inferring
state from response codes.
- **Type cleanup**: `AnalyticsResultMessage` had a Zod-inferred type
wrapped in `Omit<…, "data"> & { data?: ... }` to narrow `data` to
`Record<string, unknown>[]` for callers. Replaced with a hand-written
interface alongside the Zod schema, with an explicit "keep in sync"
comment. Same runtime behavior, dramatically easier to read.
- **Format-path extraction**: `_executeWithFormatFallback` was a 120-line
function handling two distinct format paths inline. Split into:
- `_executeJsonArrayPath` — JSON_ARRAY with ARROW_STREAM retry
- `_executeArrowStreamPath` — ARROW_STREAM with EXTERNAL_LINKS fallback
- `_stashAndEmitInline` — the cap/overflow/exhaustion logic that both
paths share, pulled into one named method
`_executeWithFormatFallback` is now a thin dispatcher. Each method's
failure modes are documented in its docstring so the contract is
visible without reading the body.
Validation: 2,098 appkit + 292 appkit-ui tests pass; tsc clean on
appkit / appkit-ui / shared; biome clean on changed files.
Co-authored-by: Isaac
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
…forward
Follow-up to the error-sanitization work on #329. The clientMessage
default of "Query execution failed" was applied uniformly to every
ExecutionError.statementFailed call, which hid genuinely user-facing
errors — the user's own SQL "Table 'foo' not found", "Syntax error
near ',' on line 3", etc. — and forced users to debug in the dark.
The fix is *not* to remove sanitization (CWE-209 is real — control
plane messages carry correlation IDs, internal RPC paths, stack traces),
but to distinguish the two sources by `ServiceErrorCode`:
1. **DBR (data plane)** — populates `errorDisplayMessage` for
user-authored SQL errors and surfaces them via the gateway untouched.
Source: `sqlgateway/scheduler/src/main/scala/DriverRequests.scala:181-208`,
`ErrorInfo.withMessage(command.errorDisplayMessage).withErrorReturnedFromDataPlane(true)`.
Codes: BAD_REQUEST, NOT_FOUND, ALREADY_EXISTS, DEADLINE_EXCEEDED,
CANCELLED, UNAUTHENTICATED.
2. **Control plane** — wraps internal RPC failures, scheduler exceptions,
capacity rejections. Source:
`sqlgateway/scheduler/src/main/scala/utils/CommandUtils.scala`,
`exceptions.scala`. Codes: INTERNAL_ERROR, IO_ERROR, UNKNOWN,
RESOURCE_EXHAUSTED, SERVICE_UNDER_MAINTENANCE,
TEMPORARILY_UNAVAILABLE, WORKSPACE_TEMPORARILY_UNAVAILABLE, ABORTED.
`dbsql-error-allowlist.ts` enumerates source-1 codes. The Set is
default-deny: any code not on the list — including future SDK additions
the allowlist hasn't yet been reviewed against — collapses to the
generic clientMessage. An explicit `clientMessage` argument to
`statementFailed()` always wins.
Server-side `.message` always preserves the raw upstream text for
`logger.error` capture; sanitization is purely at the wire boundary.
Validation: 2,129 appkit tests pass (+31 new); tsc clean; biome clean.
Co-authored-by: Isaac
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
@jamesbroadhead
jamesbroadhead requested a review from a team as a code ownerMay 21, 2026 22:16
@jamesbroadhead
jamesbroadhead requested review from MarioCadenas and removed request for a teamMay 21, 2026 22:16
Reviewer pushback on the original allowlist: TEMPORARILY_UNAVAILABLE,
RESOURCE_EXHAUSTED, SERVICE_UNDER_MAINTENANCE, WORKSPACE_TEMPORARILY_-
UNAVAILABLE, and ABORTED carry messages that ARE the entire point of
having an error string at the wire — telling users "Query execution
failed" when the actual cause is a quota / maintenance / shard-moving
condition forces them to guess. Allowlisting these.
Verified against universe sources before reclassifying:
- TEMPORARILY_UNAVAILABLE: stable template "DBSQL temporarily
unavailable. Please try again in a few minutes." constructed at
`sqlgateway/scheduler/api/src/main/scala/client/SchedulerRpcClient.scala`
and `client/linkstore/SqlGatewayClerk.scala`.
- RESOURCE_EXHAUSTED: stable user-actionable templates from
`SchedulerRpcValidatorHook.scala`: "The maximum number of warehouses
has been reached. Please contact Databricks support.", "You've hit
the limit for warehouses for free usage. Stop or delete existing
warehouses to free up capacity."
- WORKSPACE_TEMPORARILY_UNAVAILABLE: workspace-level shard-routing
messages at `sqlgateway/proxy/src/main/scala/thrift/Exceptions.scala`
— names a shard ID, which is internal topology metadata but
low-sensitivity and operationally useful for retry.
- ABORTED: short reason strings like "version mismatch" at
`ReydenWarehouseMonitor`.
- SERVICE_UNDER_MAINTENANCE: maintenance-window template messages.
INTERNAL_ERROR, IO_ERROR, UNKNOWN stay denied — these wrap raw
`ex.getMessage` and interpolate orgIds, warehouse IDs, stack traces.
`EndpointModel.scala` emits "SQL ${conf.warehouse} cannot be created
due to repeated collisions on unique IDs." — exactly the kind of
internal-state leak the sanitization exists to prevent.
Reframed the module docs from "DBR vs control plane" to
"designed-for-user vs designed-for-debugging" — the same code can be
constructed at user-facing or debug-facing sites, but the contested
5xx-class codes are overwhelmingly the former in practice.
Co-authored-by: Isaac
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
@jamesbroadhead

Copy link
Copy Markdown
ContributorAuthor

Pushed a5c8e4a4 — expanded the allowlist after reviewer pushback on the 5xx-class codes.

Newly allowlisted (all verified against universe construction sites):

  • TEMPORARILY_UNAVAILABLE — stable template "DBSQL temporarily unavailable. Please try again in a few minutes." (SchedulerRpcClient.scala, SqlGatewayClerk.scala)
  • RESOURCE_EXHAUSTED — explicit user-actionable templates: "The maximum number of warehouses has been reached. Please contact Databricks support.", "Stop or delete existing warehouses to free up capacity." (SchedulerRpcValidatorHook.scala)
  • WORKSPACE_TEMPORARILY_UNAVAILABLE — shard-routing messages (proxy/thrift/Exceptions.scala); names a shard ID, low-sensitivity, retry-relevant
  • SERVICE_UNDER_MAINTENANCE — maintenance-window templates
  • ABORTED — short reason strings ("version mismatch")

Still denied — these wrap raw ex.getMessage or interpolate internal identifiers, so leak risk is concrete:

  • INTERNAL_ERROREndpointModel.scala emits "SQL ${conf.warehouse} cannot be created due to repeated collisions on unique IDs."; SchedulerRpcContextValidatorHook.workspaceNotOwned(orgId) interpolates orgId
  • IO_ERROR — storage / network paths
  • UNKNOWN — unclassified by definition

Also reframed the module-level docs from "DBR vs control plane" to "designed-for-user vs designed-for-debugging" — same construction sites can land in either bucket, and the latter framing matches how the codes are actually used in practice.

… triage
Threat model walkthrough — the recipient of these messages is an
authenticated workspace user, not an anonymous internet visitor. They
already know:
- Their workspace URL and orgId (visible in every SDK call)
- The warehouses they have grants on (listable via the SDK)
- Their catalog contents
Interpolated identifiers in INTERNAL_ERROR / IO_ERROR messages
(warehouse names, orgIds, internal class names) are not new information
to this audience — the classic CWE-209 leak-to-anonymous-attacker
scenario does not apply. The cost of denial (every internal error
becomes "Query execution failed" with zero signal) is certain; the
benefit (defense-in-depth against unbounded wrapped `ex.getMessage`
content) is speculative.
Allowlisting both, with a requestId on the SSE error frame as the
safety net:
- `SSEError` now carries `requestId` (the SSE event id, same value the
server logs in its `Stream execution failed` line — quoting it in a
support ticket lets staff grep logs directly).
- `SSEWriter.writeError` populates `requestId` from the event id.
- `stream-manager.ts` logs `requestId=...` alongside `rawMsg`,
`errorCode`, `upstreamCode` so a user-supplied requestId pinpoints
the exact log line.
- Analytics `/arrow-result` 410/404 paths generate their own requestId
and emit it in both the JSON response and the server log line.
- `useAnalyticsQuery` exposes `requestId: string | null` on
`UseAnalyticsQueryResult` so UI code can render "Error ref: <id>".
Only `UNKNOWN` remains denied — unclassified by definition, so the
contents are unbounded in both shape and source.
Validation: 2,133 appkit + 292 appkit-ui tests pass; tsc clean.
Co-authored-by: Isaac
Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has had no activity for 23 days and has been marked as stale. It will be closed in 7 days if there is no further activity. Add a comment, push a commit, or apply the no-stale label to keep it open.

Base automatically changed from stack/arrow-3-inline-arrow-fix to mainJuly 9, 2026 12:28
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has had no activity for 23 days and has been marked as stale. It will be closed in 7 days if there is no further activity. Add a comment, push a commit, or apply the no-stale label to keep it open.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically closed because it had no activity for a month. Feel free to reopen it if you would like to continue the work.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jamesbroadhead