Skip to content

Commit 9a26ef9

Browse files
authored
Add APIError class with HTTP error parsing (#13)
* Add APIError class with HTTP error parsing * update * add a todo
1 parent 8bdb698 commit 9a26ef9

3 files changed

Lines changed: 627 additions & 0 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import {z} from 'zod';
2+
3+
import {Code, codeFromString} from './codes';
4+
import type {ErrorDetails} from './details';
5+
import {parseErrorDetails} from './details';
6+
7+
// Reusable schema fragment for nullish string fields.
8+
const nullishString = z
9+
.string()
10+
.nullish()
11+
.transform(v => v ?? '');
12+
13+
// Zod schema for parsing the JSON error response body. The schema is lenient
14+
// to handle the various Databricks API error formats (standard, legacy, SCIM).
15+
const errorResponseSchema = z.object({
16+
message: nullishString,
17+
details: z
18+
.array(z.unknown())
19+
.nullish()
20+
.transform(v => v ?? []),
21+
// Some Databricks APIs incorrectly return the HTTP status code as an
22+
// integer rather than the actual error code as a string.
23+
error_code: z.unknown().optional(),
24+
// Legacy Databricks APIs (e.g. version 1.2 and earlier) used "error"
25+
// instead of "message".
26+
error: nullishString,
27+
// SCIM error fields (RFC7644 section 3.7.3).
28+
// The "status" field is intentionally omitted; it duplicates HTTP status.
29+
detail: nullishString,
30+
scimType: nullishString,
31+
});
32+
33+
// Constructor options for APIError.
34+
interface APIErrorOptions {
35+
code: Code;
36+
message: string;
37+
details: ErrorDetails;
38+
httpStatusCode?: number;
39+
httpHeader?: Headers;
40+
httpBody?: Uint8Array;
41+
cause?: unknown;
42+
}
43+
44+
/** APIError is a transport-agnostic error representing a Databricks API error. */
45+
export class APIError extends Error {
46+
/** The canonical error code of the error. */
47+
readonly code: Code;
48+
49+
/**
50+
* The structured error details of the error. This is left empty if the
51+
* error response is not a standard Databricks API error.
52+
*/
53+
readonly details: ErrorDetails;
54+
55+
// The raw HTTP error details, undefined if this is not an HTTP error.
56+
private readonly httpErr?: {
57+
readonly statusCode: number;
58+
readonly header: Headers | undefined;
59+
readonly body: Uint8Array | undefined;
60+
};
61+
62+
/**
63+
* Do not use this constructor directly. Use {@link APIError.fromHttpError}
64+
* instead. This constructor is only meant for internal and testing use.
65+
* TODO: Make this constructor private.
66+
*
67+
* @private
68+
*/
69+
constructor(options: APIErrorOptions) {
70+
super(options.message, {cause: options.cause});
71+
this.name = 'APIError';
72+
this.code = options.code;
73+
this.details = options.details;
74+
if (options.httpStatusCode !== undefined) {
75+
this.httpErr = {
76+
statusCode: options.httpStatusCode,
77+
header: options.httpHeader,
78+
body: options.httpBody,
79+
};
80+
}
81+
}
82+
83+
/**
84+
* HTTPStatusCode returns the APIError's HTTP status code. If the APIError
85+
* is not an HTTP error, it returns -1.
86+
*/
87+
get httpStatusCode(): number {
88+
if (this.httpErr === undefined) {
89+
return -1;
90+
}
91+
return this.httpErr.statusCode;
92+
}
93+
94+
/**
95+
* HTTPHeader returns the APIError's HTTP headers. If the APIError is not
96+
* an HTTP error, it returns undefined.
97+
*/
98+
get httpHeader(): Headers | undefined {
99+
if (this.httpErr === undefined) {
100+
return undefined;
101+
}
102+
return this.httpErr.header;
103+
}
104+
105+
/**
106+
* HTTPBody returns the APIError's HTTP body. If the APIError is not an HTTP
107+
* error, it returns undefined.
108+
*/
109+
get httpBody(): Uint8Array | undefined {
110+
if (this.httpErr === undefined) {
111+
return undefined;
112+
}
113+
return this.httpErr.body;
114+
}
115+
116+
/**
117+
* Parses an HTTP error response into an APIError. Returns undefined if the
118+
* status code is 2xx.
119+
*/
120+
static fromHttpError(
121+
statusCode: number,
122+
header: Headers | undefined,
123+
body: Uint8Array | undefined
124+
): APIError | undefined {
125+
if (statusCode >= 200 && statusCode < 300) {
126+
return undefined;
127+
}
128+
129+
const emptyDetails: ErrorDetails = {unknownDetails: []};
130+
131+
if (body === undefined || body.length === 0) {
132+
return new APIError({
133+
code: toCode(statusCode),
134+
message: '',
135+
details: emptyDetails,
136+
httpStatusCode: statusCode,
137+
httpHeader: header,
138+
httpBody: body,
139+
});
140+
}
141+
142+
// Decode the body to a string for JSON parsing.
143+
let parsed: unknown;
144+
try {
145+
parsed = JSON.parse(new TextDecoder().decode(body));
146+
} catch (e: unknown) {
147+
// The JSON error is simply swallowed, this typically happens when the
148+
// error does not come directly from a Databricks API. A typical example
149+
// is when the error is returned by a proxy.
150+
return new APIError({
151+
code: toCode(statusCode),
152+
message: '',
153+
details: emptyDetails,
154+
httpStatusCode: statusCode,
155+
httpHeader: header,
156+
httpBody: body,
157+
cause: e instanceof Error ? e : undefined,
158+
});
159+
}
160+
161+
const result = errorResponseSchema.safeParse(parsed);
162+
if (!result.success) {
163+
return new APIError({
164+
code: toCode(statusCode),
165+
message: '',
166+
details: emptyDetails,
167+
httpStatusCode: statusCode,
168+
httpHeader: header,
169+
httpBody: body,
170+
cause: result.error,
171+
});
172+
}
173+
174+
const errResp = result.data;
175+
176+
// Error codes may be missing or be an integer (legacy APIs). In such
177+
// cases, defer to the HTTP status code to infer the closest canonical
178+
// error code.
179+
let errorCode: Code;
180+
if (typeof errResp.error_code === 'string') {
181+
errorCode = codeFromString(errResp.error_code);
182+
} else {
183+
errorCode = toCode(statusCode);
184+
}
185+
186+
// Determine the error message from available fields.
187+
let errorMessage = '';
188+
if (errResp.message !== '') {
189+
errorMessage = errResp.message;
190+
} else if (errResp.error !== '') {
191+
errorMessage = errResp.error;
192+
} else if (errResp.detail !== '') {
193+
errorMessage = errResp.detail;
194+
} else if (errResp.scimType !== '') {
195+
errorMessage = errResp.scimType;
196+
}
197+
198+
return new APIError({
199+
code: errorCode,
200+
message: errorMessage,
201+
details: parseErrorDetails(errResp.details),
202+
httpStatusCode: statusCode,
203+
httpHeader: header,
204+
httpBody: body,
205+
});
206+
}
207+
}
208+
209+
// Maps an HTTP status code to the closest canonical error code.
210+
export function toCode(httpCode: number): Code {
211+
// Canonical mappings.
212+
switch (httpCode) {
213+
case 200:
214+
return Code.OK;
215+
case 400:
216+
return Code.INVALID_ARGUMENT;
217+
case 401:
218+
return Code.UNAUTHENTICATED;
219+
case 403:
220+
return Code.PERMISSION_DENIED;
221+
case 404:
222+
return Code.NOT_FOUND;
223+
case 409:
224+
return Code.ABORTED;
225+
case 416:
226+
return Code.OUT_OF_RANGE;
227+
case 429:
228+
return Code.RESOURCE_EXHAUSTED;
229+
case 501:
230+
return Code.UNIMPLEMENTED;
231+
case 503:
232+
return Code.UNAVAILABLE;
233+
case 504:
234+
return Code.DEADLINE_EXCEEDED;
235+
default:
236+
break;
237+
}
238+
239+
// Fallback for status codes without a direct canonical mapping.
240+
if (httpCode >= 200 && httpCode < 300) {
241+
return Code.OK;
242+
}
243+
if (httpCode >= 400 && httpCode < 500) {
244+
// Most non-canonical 4xx status codes are state related and map
245+
// to the definition of FailedPrecondition.
246+
return Code.FAILED_PRECONDITION;
247+
}
248+
if (httpCode >= 500 && httpCode < 600) {
249+
return Code.INTERNAL;
250+
}
251+
252+
return Code.UNKNOWN;
253+
}

packages/databricks/src/apierror/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* @packageDocumentation
55
*/
66

7+
export {APIError} from './apierror';
8+
79
export type {
810
ErrorDetails,
911
ErrorInfo,

0 commit comments

Comments
 (0)