diff --git a/packages/databricks/src/apierror/apierror.ts b/packages/databricks/src/apierror/apierror.ts new file mode 100644 index 00000000..384150ad --- /dev/null +++ b/packages/databricks/src/apierror/apierror.ts @@ -0,0 +1,253 @@ +import {z} from 'zod'; + +import {Code, codeFromString} from './codes'; +import type {ErrorDetails} from './details'; +import {parseErrorDetails} from './details'; + +// Reusable schema fragment for nullish string fields. +const nullishString = z + .string() + .nullish() + .transform(v => v ?? ''); + +// Zod schema for parsing the JSON error response body. The schema is lenient +// to handle the various Databricks API error formats (standard, legacy, SCIM). +const errorResponseSchema = z.object({ + message: nullishString, + details: z + .array(z.unknown()) + .nullish() + .transform(v => v ?? []), + // Some Databricks APIs incorrectly return the HTTP status code as an + // integer rather than the actual error code as a string. + error_code: z.unknown().optional(), + // Legacy Databricks APIs (e.g. version 1.2 and earlier) used "error" + // instead of "message". + error: nullishString, + // SCIM error fields (RFC7644 section 3.7.3). + // The "status" field is intentionally omitted; it duplicates HTTP status. + detail: nullishString, + scimType: nullishString, +}); + +// Constructor options for APIError. +interface APIErrorOptions { + code: Code; + message: string; + details: ErrorDetails; + httpStatusCode?: number; + httpHeader?: Headers; + httpBody?: Uint8Array; + cause?: unknown; +} + +/** APIError is a transport-agnostic error representing a Databricks API error. */ +export class APIError extends Error { + /** The canonical error code of the error. */ + readonly code: Code; + + /** + * The structured error details of the error. This is left empty if the + * error response is not a standard Databricks API error. + */ + readonly details: ErrorDetails; + + // The raw HTTP error details, undefined if this is not an HTTP error. + private readonly httpErr?: { + readonly statusCode: number; + readonly header: Headers | undefined; + readonly body: Uint8Array | undefined; + }; + + /** + * Do not use this constructor directly. Use {@link APIError.fromHttpError} + * instead. This constructor is only meant for internal and testing use. + * TODO: Make this constructor private. + * + * @private + */ + constructor(options: APIErrorOptions) { + super(options.message, {cause: options.cause}); + this.name = 'APIError'; + this.code = options.code; + this.details = options.details; + if (options.httpStatusCode !== undefined) { + this.httpErr = { + statusCode: options.httpStatusCode, + header: options.httpHeader, + body: options.httpBody, + }; + } + } + + /** + * HTTPStatusCode returns the APIError's HTTP status code. If the APIError + * is not an HTTP error, it returns -1. + */ + get httpStatusCode(): number { + if (this.httpErr === undefined) { + return -1; + } + return this.httpErr.statusCode; + } + + /** + * HTTPHeader returns the APIError's HTTP headers. If the APIError is not + * an HTTP error, it returns undefined. + */ + get httpHeader(): Headers | undefined { + if (this.httpErr === undefined) { + return undefined; + } + return this.httpErr.header; + } + + /** + * HTTPBody returns the APIError's HTTP body. If the APIError is not an HTTP + * error, it returns undefined. + */ + get httpBody(): Uint8Array | undefined { + if (this.httpErr === undefined) { + return undefined; + } + return this.httpErr.body; + } + + /** + * Parses an HTTP error response into an APIError. Returns undefined if the + * status code is 2xx. + */ + static fromHttpError( + statusCode: number, + header: Headers | undefined, + body: Uint8Array | undefined + ): APIError | undefined { + if (statusCode >= 200 && statusCode < 300) { + return undefined; + } + + const emptyDetails: ErrorDetails = {unknownDetails: []}; + + if (body === undefined || body.length === 0) { + return new APIError({ + code: toCode(statusCode), + message: '', + details: emptyDetails, + httpStatusCode: statusCode, + httpHeader: header, + httpBody: body, + }); + } + + // Decode the body to a string for JSON parsing. + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(body)); + } catch (e: unknown) { + // The JSON error is simply swallowed, this typically happens when the + // error does not come directly from a Databricks API. A typical example + // is when the error is returned by a proxy. + return new APIError({ + code: toCode(statusCode), + message: '', + details: emptyDetails, + httpStatusCode: statusCode, + httpHeader: header, + httpBody: body, + cause: e instanceof Error ? e : undefined, + }); + } + + const result = errorResponseSchema.safeParse(parsed); + if (!result.success) { + return new APIError({ + code: toCode(statusCode), + message: '', + details: emptyDetails, + httpStatusCode: statusCode, + httpHeader: header, + httpBody: body, + cause: result.error, + }); + } + + const errResp = result.data; + + // Error codes may be missing or be an integer (legacy APIs). In such + // cases, defer to the HTTP status code to infer the closest canonical + // error code. + let errorCode: Code; + if (typeof errResp.error_code === 'string') { + errorCode = codeFromString(errResp.error_code); + } else { + errorCode = toCode(statusCode); + } + + // Determine the error message from available fields. + let errorMessage = ''; + if (errResp.message !== '') { + errorMessage = errResp.message; + } else if (errResp.error !== '') { + errorMessage = errResp.error; + } else if (errResp.detail !== '') { + errorMessage = errResp.detail; + } else if (errResp.scimType !== '') { + errorMessage = errResp.scimType; + } + + return new APIError({ + code: errorCode, + message: errorMessage, + details: parseErrorDetails(errResp.details), + httpStatusCode: statusCode, + httpHeader: header, + httpBody: body, + }); + } +} + +// Maps an HTTP status code to the closest canonical error code. +export function toCode(httpCode: number): Code { + // Canonical mappings. + switch (httpCode) { + case 200: + return Code.OK; + case 400: + return Code.INVALID_ARGUMENT; + case 401: + return Code.UNAUTHENTICATED; + case 403: + return Code.PERMISSION_DENIED; + case 404: + return Code.NOT_FOUND; + case 409: + return Code.ABORTED; + case 416: + return Code.OUT_OF_RANGE; + case 429: + return Code.RESOURCE_EXHAUSTED; + case 501: + return Code.UNIMPLEMENTED; + case 503: + return Code.UNAVAILABLE; + case 504: + return Code.DEADLINE_EXCEEDED; + default: + break; + } + + // Fallback for status codes without a direct canonical mapping. + if (httpCode >= 200 && httpCode < 300) { + return Code.OK; + } + if (httpCode >= 400 && httpCode < 500) { + // Most non-canonical 4xx status codes are state related and map + // to the definition of FailedPrecondition. + return Code.FAILED_PRECONDITION; + } + if (httpCode >= 500 && httpCode < 600) { + return Code.INTERNAL; + } + + return Code.UNKNOWN; +} diff --git a/packages/databricks/src/apierror/index.ts b/packages/databricks/src/apierror/index.ts index e7ad0453..667508cf 100644 --- a/packages/databricks/src/apierror/index.ts +++ b/packages/databricks/src/apierror/index.ts @@ -4,6 +4,8 @@ * @packageDocumentation */ +export {APIError} from './apierror'; + export type { ErrorDetails, ErrorInfo, diff --git a/packages/databricks/tests/apierror/apierror.test.ts b/packages/databricks/tests/apierror/apierror.test.ts new file mode 100644 index 00000000..883415a8 --- /dev/null +++ b/packages/databricks/tests/apierror/apierror.test.ts @@ -0,0 +1,372 @@ +import {describe, it, expect} from 'vitest'; +import {APIError, toCode} from '../../src/apierror/apierror'; +import {Code} from '../../src/apierror/codes'; +import type {ErrorDetails} from '../../src/apierror/details'; + +// Helper to encode a string as Uint8Array. +function encode(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +const emptyDetails: ErrorDetails = {unknownDetails: []}; + +describe('APIError non-HTTP getters', () => { + const testCases: { + name: string; + apiErr: APIError; + wantCode: Code; + wantMessage: string; + wantDetails: ErrorDetails; + }[] = [ + { + name: 'basic fields', + apiErr: new APIError({ + code: Code.UNKNOWN, + message: '', + details: emptyDetails, + }), + wantCode: Code.UNKNOWN, + wantMessage: '', + wantDetails: emptyDetails, + }, + { + name: 'explicit values', + apiErr: new APIError({ + code: Code.INVALID_ARGUMENT, + message: 'Invalid request', + details: { + errorInfo: { + reason: 'bad_param', + domain: 'databricks.com', + metadata: {}, + }, + unknownDetails: [], + }, + }), + wantCode: Code.INVALID_ARGUMENT, + wantMessage: 'Invalid request', + wantDetails: { + errorInfo: { + reason: 'bad_param', + domain: 'databricks.com', + metadata: {}, + }, + unknownDetails: [], + }, + }, + ]; + + it.each(testCases)( + '$name', + ({apiErr, wantCode, wantMessage, wantDetails}) => { + expect(apiErr.code).toBe(wantCode); + expect(apiErr.message).toBe(wantMessage); + expect(apiErr.details).toStrictEqual(wantDetails); + } + ); +}); + +describe('APIError HTTP getters', () => { + const header = new Headers({'Content-Type': 'application/json'}); + const body = encode( + '{"error_code": "INVALID_ARGUMENT", "message": "Invalid request"}' + ); + + const testCases: { + name: string; + apiErr: APIError; + wantStatusCode: number; + wantHeader: Headers | undefined; + wantBody: Uint8Array | undefined; + }[] = [ + { + name: 'no HTTP error returns defaults', + apiErr: new APIError({ + code: Code.UNKNOWN, + message: '', + details: emptyDetails, + }), + wantStatusCode: -1, + wantHeader: undefined, + wantBody: undefined, + }, + { + name: 'with HTTP error returns stored values', + apiErr: new APIError({ + code: Code.UNKNOWN, + message: '', + details: emptyDetails, + httpStatusCode: 400, + httpHeader: header, + httpBody: body, + }), + wantStatusCode: 400, + wantHeader: header, + wantBody: body, + }, + ]; + + it.each(testCases)( + '$name', + ({apiErr, wantStatusCode, wantHeader, wantBody}) => { + expect(apiErr.httpStatusCode).toBe(wantStatusCode); + expect(apiErr.httpHeader).toBe(wantHeader); + if (wantBody === undefined) { + expect(apiErr.httpBody).toBeUndefined(); + } else { + expect(apiErr.httpBody).toStrictEqual(wantBody); + } + } + ); +}); + +describe('fromHttpError', () => { + const testCases: { + desc: string; + statusCode: number; + header?: Headers; + body?: Uint8Array; + want?: APIError; + }[] = [ + { + desc: '200 returns undefined', + statusCode: 200, + }, + { + desc: '201 returns undefined', + statusCode: 201, + }, + { + desc: '204 returns undefined', + statusCode: 204, + }, + { + desc: 'empty body with status', + statusCode: 400, + want: new APIError({ + code: Code.INVALID_ARGUMENT, + message: '', + details: emptyDetails, + }), + }, + { + desc: 'empty body with status and headers', + statusCode: 404, + header: new Headers({'Content-Type': 'application/json'}), + want: new APIError({ + code: Code.NOT_FOUND, + message: '', + details: emptyDetails, + }), + }, + { + desc: 'HTML body', + statusCode: 502, + body: encode('Bad Gateway'), + want: new APIError({ + code: Code.INTERNAL, + message: '', + details: emptyDetails, + }), + }, + { + desc: 'malformed JSON', + statusCode: 400, + body: encode('{not valid json'), + want: new APIError({ + code: Code.INVALID_ARGUMENT, + message: '', + details: emptyDetails, + }), + }, + { + desc: 'standard error no details', + statusCode: 404, + body: encode( + '{"error_code": "NOT_FOUND", "message": "Job 123 not found"}' + ), + want: new APIError({ + code: Code.NOT_FOUND, + message: 'Job 123 not found', + details: emptyDetails, + }), + }, + { + desc: 'standard error with details', + statusCode: 404, + body: encode( + JSON.stringify({ + error_code: 'NOT_FOUND', + message: 'Job 123 not found', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'bad_param', + domain: 'databricks.com', + }, + ], + }) + ), + want: new APIError({ + code: Code.NOT_FOUND, + message: 'Job 123 not found', + details: { + errorInfo: { + reason: 'bad_param', + domain: 'databricks.com', + metadata: {}, + }, + unknownDetails: [], + }, + }), + }, + { + desc: 'standard error with unknown error_code', + statusCode: 400, + body: encode( + '{"error_code": "SOME_UNKNOWN_CODE", "message": "Something went wrong"}' + ), + want: new APIError({ + code: Code.UNKNOWN, + message: 'Something went wrong', + details: emptyDetails, + }), + }, + { + desc: 'standard error with missing error_code', + statusCode: 403, + body: encode('{"message": "Access denied"}'), + want: new APIError({ + code: Code.PERMISSION_DENIED, + message: 'Access denied', + details: emptyDetails, + }), + }, + { + desc: 'standard error with integer error_code', + statusCode: 400, + body: encode('{"error_code": 42, "message": "Invalid request"}'), + want: new APIError({ + code: Code.INVALID_ARGUMENT, + message: 'Invalid request', + details: emptyDetails, + }), + }, + { + desc: 'legacy API 1.2 error field', + statusCode: 400, + body: encode('{"error": "Invalid parameter"}'), + want: new APIError({ + code: Code.INVALID_ARGUMENT, + message: 'Invalid parameter', + details: emptyDetails, + }), + }, + { + desc: 'message takes precedence over error field', + statusCode: 400, + body: encode('{"message": "New message", "error": "Old error"}'), + want: new APIError({ + code: Code.INVALID_ARGUMENT, + message: 'New message', + details: emptyDetails, + }), + }, + { + desc: 'SCIM error with detail', + statusCode: 404, + body: encode('{"detail": "User not found", "scimType": "invalidValue"}'), + want: new APIError({ + code: Code.NOT_FOUND, + message: 'User not found', + details: emptyDetails, + }), + }, + { + desc: 'SCIM error with only scimType', + statusCode: 400, + body: encode('{"scimType": "uniqueness"}'), + want: new APIError({ + code: Code.INVALID_ARGUMENT, + message: 'uniqueness', + details: emptyDetails, + }), + }, + { + desc: 'message takes precedence over SCIM detail', + statusCode: 400, + body: encode('{"message": "Standard message", "detail": "SCIM detail"}'), + want: new APIError({ + code: Code.INVALID_ARGUMENT, + message: 'Standard message', + details: emptyDetails, + }), + }, + ]; + + it.each(testCases)('$desc', tc => { + const got = APIError.fromHttpError(tc.statusCode, tc.header, tc.body); + + if (tc.want === undefined) { + expect(got).toBeUndefined(); + return; + } + + if (got === undefined) { + expect.fail('expected fromHttpError to return an APIError'); + } + + expect(got.code).toBe(tc.want.code); + expect(got.message).toBe(tc.want.message); + expect(got.details).toStrictEqual(tc.want.details); + expect(got.httpStatusCode).toBe(tc.statusCode); + expect(got.httpHeader).toBe(tc.header); + if (tc.body === undefined) { + expect(got.httpBody).toBeUndefined(); + } else { + expect(got.httpBody).toStrictEqual(tc.body); + } + }); +}); + +describe('toCode', () => { + const testCases: { + httpCode: number; + want: Code; + }[] = [ + // Direct mappings. + {httpCode: 200, want: Code.OK}, + {httpCode: 400, want: Code.INVALID_ARGUMENT}, + {httpCode: 401, want: Code.UNAUTHENTICATED}, + {httpCode: 403, want: Code.PERMISSION_DENIED}, + {httpCode: 404, want: Code.NOT_FOUND}, + {httpCode: 409, want: Code.ABORTED}, + {httpCode: 416, want: Code.OUT_OF_RANGE}, + {httpCode: 429, want: Code.RESOURCE_EXHAUSTED}, + {httpCode: 504, want: Code.DEADLINE_EXCEEDED}, + {httpCode: 501, want: Code.UNIMPLEMENTED}, + {httpCode: 503, want: Code.UNAVAILABLE}, + + // Fallback ranges. + {httpCode: 201, want: Code.OK}, + {httpCode: 204, want: Code.OK}, + {httpCode: 418, want: Code.FAILED_PRECONDITION}, + {httpCode: 500, want: Code.INTERNAL}, + {httpCode: 599, want: Code.INTERNAL}, + + // Unknown (valid). + {httpCode: 100, want: Code.UNKNOWN}, + {httpCode: 300, want: Code.UNKNOWN}, + + // Unknown (invalid). + {httpCode: -1, want: Code.UNKNOWN}, + {httpCode: 0, want: Code.UNKNOWN}, + {httpCode: 42, want: Code.UNKNOWN}, + {httpCode: 600, want: Code.UNKNOWN}, + {httpCode: 1337, want: Code.UNKNOWN}, + ]; + + it.each(testCases)('status $httpCode', ({httpCode, want}) => { + expect(toCode(httpCode)).toBe(want); + }); +});