Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 61 additions & 15 deletions packages/appkit/src/type-generator/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,23 +180,46 @@ export function classifyBlockingFailure(
return "environmental";
}

// Databricks REST/SDK auth error codes. These arrive as an `error_code` string
// (a top-level field, or a JSON body embedded in the message) rather than a
// numeric HTTP status, so status-only detection misses them.
const AUTH_ERROR_CODES = new Set(["PERMISSION_DENIED", "UNAUTHENTICATED"]);

/**
* Coarse cause label for an environmental failure, used by the `--wait`
* committed-types warning so the log says *why* generation fell back.
*
* Returns:
* - "unreachable": transport/connectivity failure (see {@link isConnectivityError}).
* - "auth": HTTP 401/403, including a status carried on `response.status` or
* wrapped in a `cause`/`AggregateError` chain.
* - "unavailable": everything else (DELETED/DELETING, wait timeouts, degraded
* DESCRIBEs).
* Extract a Databricks `error_code` (e.g. "PERMISSION_DENIED") from a thrown
* error. The SDK surfaces it either as a top-level `error_code` field or as a
* JSON body embedded in the message string, e.g.
* `Response from server (Forbidden) {"error_code":"PERMISSION_DENIED",...}`.
*/
export function classifyEnvironmentalCause(
error: unknown,
): "auth" | "unreachable" | "unavailable" {
if (isConnectivityError(error)) return "unreachable";
function getDatabricksErrorCode(error: unknown): string | undefined {
if (!isObject(error)) return undefined;
if (typeof error.error_code === "string") return error.error_code;

const message = typeof error.message === "string" ? error.message : undefined;
const jsonMatch = message?.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]) as { error_code?: unknown };
if (typeof parsed.error_code === "string") return parsed.error_code;
} catch {
// not valid JSON — fall through
}
}
return undefined;
}

// Walk the error chain so a wrapped 401/403 is still labeled as auth.
/**
* True when a thrown failure is an authentication/authorization problem: an
* HTTP 401/403, or a Databricks `error_code` of PERMISSION_DENIED /
* UNAUTHENTICATED (which can arrive with no numeric status). Walks
* `cause`/`AggregateError` chains so a wrapped auth error is still recognized.
*
* Callers degrade rather than fail the build on `true`: a build-time identity
* gap — the build runs as a different principal than the app's runtime
* on-behalf-of user — must not block a deploy when committed types exist. The
* has-types gate still crashes a fresh checkout with nothing to fall back to.
*/
export function isAuthError(error: unknown): boolean {
const seen = new Set<unknown>();
const stack = [error];

Expand All @@ -206,10 +229,33 @@ export function classifyEnvironmentalCause(
seen.add(current);

const status = getErrorStatus(current);
if (status !== undefined && AUTH_ERROR_STATUSES.has(status)) return "auth";
if (status !== undefined && AUTH_ERROR_STATUSES.has(status)) return true;

const code = getDatabricksErrorCode(current);
if (code && AUTH_ERROR_CODES.has(code)) return true;

stack.push(...getErrorChildren(current));
}

return false;
}

/**
* Coarse cause label for an environmental failure, used by the `--wait`
* committed-types warning so the log says *why* generation fell back.
*
* Returns:
* - "unreachable": transport/connectivity failure (see {@link isConnectivityError}).
* - "auth": HTTP 401/403 or a PERMISSION_DENIED / UNAUTHENTICATED `error_code`,
* including one carried on `response.status` or wrapped in a
* `cause`/`AggregateError` chain (see {@link isAuthError}).
* - "unavailable": everything else (DELETED/DELETING, wait timeouts, degraded
* DESCRIBEs).
*/
export function classifyEnvironmentalCause(
error: unknown,
): "auth" | "unreachable" | "unavailable" {
if (isConnectivityError(error)) return "unreachable";
if (isAuthError(error)) return "auth";
return "unavailable";
}
20 changes: 14 additions & 6 deletions packages/appkit/src/type-generator/mv-registry/sync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getErrorDiagnostic, isConnectivityError } from "../errors";
import { classifyBlockingFailure, getErrorDiagnostic } from "../errors";
import type { DatabricksStatementExecutionResponse } from "../types";
import {
extractMetricColumns,
Expand Down Expand Up @@ -73,10 +73,18 @@ export async function syncMetrics(
response = await fetcher(entry.source);
} catch (err) {
const reason = `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(err)}`;
// Connectivity blips self-converge (retry next pass); auth, a bad
// warehouse id, a truncated / multi-chunk result, or a malformed request
// are deterministic and must surface — the same split the query path makes.
return failedOutcome(index, entry, reason, isConnectivityError(err));
// The DESCRIBE never ran (fetcher threw). Degrade by default (connectivity,
// auth/permission, SDK/config) so the has-types gate can reuse committed
// types; only the deny-list of deterministic client errors (bad warehouse
// id 404, malformed request 400) surfaces as fatal — the same split the
// query path and preflight make. (Truncated/multi-chunk and zero-column
// responses are ran-and-failed, handled below and kept non-transient.)
Comment thread
atilafassina marked this conversation as resolved.
return failedOutcome(
index,
entry,
reason,
classifyBlockingFailure(err) !== "deterministic",
);
}

const state = response.status?.state;
Expand Down Expand Up @@ -139,7 +147,7 @@ export async function syncMetrics(
index,
entry,
`DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(result.reason)}`,
isConnectivityError(result.reason),
classifyBlockingFailure(result.reason) !== "deterministic",
);
schemas[index] = schema;
failureSlots[index] = failure;
Expand Down
7 changes: 5 additions & 2 deletions packages/appkit/src/type-generator/mv-registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,11 @@ export interface MetricSyncFailure {
/** Single human-readable reason (DESCRIBE failed, parse failed, zero columns). */
reason: string;
/**
* Whether the failure is expected to self-converge on a later pass without
* a config change.
* Whether the failure should degrade rather than fail the build. True for any
* DESCRIBE that never ran (connectivity, auth/permission, SDK/config) — the
* has-types gate reuses committed types. False for the deny-list of
* deterministic client errors (bad warehouse id 404, malformed request 400)
* and ran-and-failed responses (unparseable payload, zero columns).
*/
transient: boolean;
}
Expand Down
27 changes: 19 additions & 8 deletions packages/appkit/src/type-generator/query-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -954,9 +954,12 @@ export async function generateQueriesFromDescribe(
}
} else {
// executeStatement rejected without a normal StatementExecution result.
// Only structured transport/connectivity failures are treated as
// offline; auth, bad warehouse IDs, malformed requests, and SDK/config
// failures stay fatal so users fix the underlying setup issue.
// executeStatement rejected — the statement never ran. Degrade by
// default (connectivity, auth/permission, SDK/config); the has-types
// gate reuses committed types, or crashes a fresh checkout with
// nothing to fall back to. Only the deny-list below — deterministic
// client errors — stays fatal. (Bad SQL is a *ran-and-failed*
// statement, handled by the syntax-error branch above, not here.)
completed++;
spinner.update(
`Describing ${total} ${total === 1 ? "query" : "queries"} (${completed}/${total})`,
Expand All @@ -974,7 +977,9 @@ export async function generateQueriesFromDescribe(
schema: { name: queryName, ...degraded },
});

if (!isConnectivityError(entry.reason)) {
if (classifyBlockingFailure(entry.reason) === "deterministic") {
// Deny-list: a bad/typo'd warehouse id (404) or a malformed
// request (400) is a config error — surface it so users fix it.
fatalErrors.push({ name: queryName, message: error.message });
logEntries.push({
queryName,
Expand All @@ -985,16 +990,22 @@ export async function generateQueriesFromDescribe(
continue;
}

// Environmental for the same reason as the preflight connectivity
// branch above, so the has-types gate still sees it.
// Not on the deny-list: degrade so the has-types gate can reuse
// committed types, exactly as the preflight branch above does. This
// covers connectivity blips and build-time auth/permission gaps (the
// build runs as a different principal than the app's runtime
// on-behalf-of user), which must not fail a deploy when committed
// types exist.
if (mode === "blocking") {
hadEnvironmentalFailure = true;
environmentalCause = environmentalCause ?? "unreachable";
environmentalCause =
environmentalCause ?? classifyEnvironmentalCause(entry.reason);
}
Comment thread
atilafassina marked this conversation as resolved.

logger.warn(
"DESCRIBE unreachable for %s: %s — %s",
"DESCRIBE degraded for %s (%s): %s — %s",
queryName,
classifyEnvironmentalCause(entry.reason),
reason,
canReusePrior
? "reusing last cached type"
Expand Down
67 changes: 66 additions & 1 deletion packages/appkit/src/type-generator/tests/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";

import { classifyBlockingFailure, classifyEnvironmentalCause } from "../errors";
import {
classifyBlockingFailure,
classifyEnvironmentalCause,
isAuthError,
} from "../errors";

describe("classifyBlockingFailure", () => {
describe("deterministic failures", () => {
Expand Down Expand Up @@ -291,4 +295,65 @@ describe("classifyEnvironmentalCause", () => {
])("labels %s as unavailable", (_name, error) => {
expect(classifyEnvironmentalCause(error)).toBe("unavailable");
});

it("labels a PERMISSION_DENIED error_code (no status) as auth", () => {
const error = Object.assign(new Error("2f1a9c…"), {
error_code: "PERMISSION_DENIED",
});
expect(classifyEnvironmentalCause(error)).toBe("auth");
});
});

describe("isAuthError", () => {
it.each([401, 403])("detects HTTP %i", (status) => {
expect(isAuthError(Object.assign(new Error("Denied"), { status }))).toBe(
true,
);
});

it("detects a PERMISSION_DENIED error_code carried with no numeric status", () => {
// The shape observed on deploy: an error_code string, no HTTP status.
const error = Object.assign(new Error("2f1a9c…"), {
error_code: "PERMISSION_DENIED",
});
expect(isAuthError(error)).toBe(true);
});

it("detects UNAUTHENTICATED via error_code", () => {
const error = Object.assign(new Error("no token"), {
error_code: "UNAUTHENTICATED",
});
expect(isAuthError(error)).toBe(true);
});

it("detects error_code embedded as a JSON body in the message", () => {
const error = new Error(
'Response from server (Forbidden) {"error_code":"PERMISSION_DENIED","message":"nope"}',
);
expect(isAuthError(error)).toBe(true);
});

it("detects an auth status wrapped in a cause chain", () => {
const error = new Error("Request failed", {
cause: Object.assign(new Error("Denied"), { status: 403 }),
});
expect(isAuthError(error)).toBe(true);
});

it.each([
["a bad-id 404", Object.assign(new Error("Not found"), { status: 404 })],
["a 400", Object.assign(new Error("Bad request"), { status: 400 })],
[
"a connectivity code",
Object.assign(new Error("x"), { code: "ECONNREFUSED" }),
],
[
"a non-auth error_code",
Object.assign(new Error("x"), { error_code: "TABLE_OR_VIEW_NOT_FOUND" }),
],
["a plain error", new Error("boom")],
["a non-object", "just a string"],
])("returns false for %s", (_name, error) => {
expect(isAuthError(error)).toBe(false);
});
});
Loading
Loading