diff --git a/CHANGELOG.md b/CHANGELOG.md index afeafa66..94522771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,121 @@ This driver uses semantic versioning: ## [Unreleased] +### Changed + +- Errors encountered before a request completes are now wrapped in a `NetworkError` + + This should help making it easier to diagnose network issues and distinguish + the relevant error conditions. + + The originating error can still be accessed using the `cause` property of the + `NetworkError` error. + +- `HttpError` now extends the `NetworkError` type + + This allows treating all non-`ArangoError` errors as one category of errors, + even when there is no server response available. + +- `db.waitForPropagation` now throws a `PropagationTimeoutError` error when + invoked with a `timeout` option and the timeout duration is exceeded + + The method would previously throw the most recent error encountered while + waiting for replication. The originating error can still be accessed using + the `cause` property of the `PropagationTimeoutError` error. + +- `db.waitForPropagation` now respects the `timeout` option more strictly + + Previously the method would only time out if the timeout duration was + exceeded after the most recent request failed. Now the timeout is + recalculated and passed on to each request, preventing it from exceeding + the specified duration. + + If the propagation timed out due to an underlying request exceeding the + timeout duration, the `cause` property of the `PropagationTimeoutError` + error will be a `ResponseTimeoutError` error. + +- `config.beforeRequest` and `config.afterResponse` callbacks can now return + promises + + If the callback returns a promise, it will be awaited before the request + and response cycle proceeds. If either callback throws an error or returns + a promise that is rejected, that error will be thrown instead. + +- `config.afterResponse` callback signature changed + + The callback signature previously used the internal `ArangojsResponse` type. + The new signature uses the `Response` type of the Fetch API with an + additional `request` property to more accurately represent the actual value + it receives as the `parsedBody` property will never be present. + +- `response` property on `ArangoError` is now optional + + This property should always be present but this allows using the error in + situations where a response might not be available. + ### Added +- Added `onError` option to `Config` (DE-955) + + This option can be used to specify a callback function that will be invoked + whenever a request results in an error. Unlike `afterResponse`, this callback + will be invoked even if the request completed but returned an error status. + In this case the error will be the `HttpError` or `ArangoError` representing + the error response. + + If the `onError` callback throws an error or returns a promise that is + rejected, that error will be thrown instead. + +- Added `NetworkError` error type + + This is the common base class for all errors (including `HttpError`) that + occur while making a request. The originating error can be accessed using the + `cause` property. The request object can be accessed using the `request` + property. + + Note that `ArangoError` and the new `PropagationTimeoutError` error type + do not extend `NetworkError` but may wrap an underlying error, which can + be accessed using the `cause` property. + +- Added `ResponseTimeoutError` error type + + This error extends `NetworkError` and is thrown when a request deliberately + times out using the `timeout` option. + +- Added `RequestAbortedError` error type + + This error extends `NetworkError` and is thrown when a request is aborted + by using the `db.close` method. + +- Added `FetchFailedError` error type + + This error extends `NetworkError` and is thrown when a request fails because + the underlying `fetch` call fails (usually with a `TypeError`). + + In Node.js the root cause of this error (e.g. a network failure) can often be + found in the `cause` property of the originating error, i.e. the `cause` + property of the `cause` property of this error. + + In browsers the root cause is usually not exposed directly but can often + be diagnosed by examining the developer console or network tab. + +- Added `PropagationTimeoutError` error type + + This error does not extend `NetworkError` but wraps the most recent error + encountered while waiting for replication, which can be accessed using the + `cause` property. This error is only thrown when `db.waitForPropagation` + is invoked with a `timeout` option and the timeout duration is exceeded. + +- Added `ProcessedResponse` type + + This type replaces the previously internal `ArangojsResponse` type and + extends the native `Response` type with additional properties. + +- Added optional `request` property to `ArangoError` + + This property is always present if the error has a `response` property. In + normal use this should always be the case. + - Added `database` property to `Analyzer`, `ArrayCursor`, `BatchedArrayCursor`, `Collection`, `Graph`, `Job`, `Route`, `Transaction` and `View` types (DE-935) diff --git a/src/connection.ts b/src/connection.ts index 2565226e..645830ac 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -13,18 +13,17 @@ import { Database } from "./database.js"; import { ArangoError, HttpError, + NetworkError, + PropagationTimeoutError, isArangoError, isArangoErrorResponse, - isSystemError, + isNetworkError, } from "./error.js"; import { ERROR_ARANGO_CONFLICT, - ERROR_ARANGO_MAINTENANCE_MODE, } from "./lib/codes.js"; import { normalizeUrl } from "./lib/normalizeUrl.js"; import { - ArangojsError, - ArangojsResponse, createRequest, RequestConfig, RequestFunction, @@ -162,8 +161,6 @@ export type RequestOptions = { /** * Time in milliseconds after which arangojs will abort the request if the * socket has not already timed out. - * - * See also `agentOptions.timeout` in {@link Config}. */ timeout?: number; /** @@ -180,17 +177,37 @@ export type RequestOptions = { search?: URLSearchParams | Record; }; +/** + * Processed response object. + */ +export interface ProcessedResponse extends globalThis.Response { + /** + * @internal + * + * Identifier of the ArangoDB host that served this request. + */ + arangojsHostUrl?: string; + /** + * Fetch request object. + */ + request: globalThis.Request; + /** + * Parsed response body. + */ + parsedBody?: T; +}; + /** * @internal */ -type Task = { +type Task = { hostUrl?: string; stack?: () => string; allowDirtyRead: boolean; retryOnConflict: number; - resolve: (result: any) => void; - reject: (error: Error) => void; - transform?: (res: ArangojsResponse) => any; + resolve: (result: T) => void; + reject: (error: unknown) => void; + transform?: (res: ProcessedResponse) => T; retries: number; options: { method: string; @@ -346,7 +363,7 @@ export type Config = { * * @param req - Request object or XHR instance used for this request. */ - beforeRequest?: (req: globalThis.Request) => void; + beforeRequest?: (req: globalThis.Request) => void | Promise; /** * Callback that will be invoked when the server response has been received * and processed or when the request has been failed without a response. @@ -357,7 +374,13 @@ export type Config = { * @param err - Error encountered when handling this request or `null`. * @param res - Response object for this request, if no error occurred. */ - afterResponse?: (err: ArangojsError | null, res?: ArangojsResponse) => void; + afterResponse?: (err: NetworkError | null, res?: globalThis.Response & { request: globalThis.Request }) => void | Promise; + /** + * Callback that will be invoked when a request + * + * @param err - Error encountered when handling this request. + */ + onError?: (err: Error) => void | Promise; /** * If set to a positive number, requests will automatically be retried at * most this many times if they result in a write-write conflict. @@ -425,6 +448,7 @@ export class Connection { protected _activeHostUrl: string; protected _activeDirtyHostUrl: string; protected _transactionId: string | null = null; + protected _onError?: (err: Error) => void | Promise; protected _precaptureStackTraces: boolean; protected _queueTimes = new LinkedList<[number, number]>(); protected _responseQueueTimeSamples: number; @@ -466,6 +490,7 @@ export class Connection { this._precaptureStackTraces = Boolean(config.precaptureStackTraces); this._responseQueueTimeSamples = config.responseQueueTimeSamples ?? 10; this._retryOnConflict = config.retryOnConflict ?? 0; + this._onError = config.onError; if (this._responseQueueTimeSamples < 0) { this._responseQueueTimeSamples = Infinity; } @@ -520,31 +545,29 @@ export class Connection { } protected async _runQueue() { - if (!this._queue.length || this._activeTasks >= this._taskPoolSize) return; - const task = this._queue.shift()!; + if (this._activeTasks >= this._taskPoolSize) return; + const task = this._queue.shift(); + if (!task) return; let hostUrl = this._activeHostUrl; - if (task.hostUrl !== undefined) { - hostUrl = task.hostUrl; - } else if (task.allowDirtyRead) { - hostUrl = this._activeDirtyHostUrl; - this._activeDirtyHostUrl = - this._hostUrls[ - (this._hostUrls.indexOf(this._activeDirtyHostUrl) + 1) % - this._hostUrls.length - ]; - task.options.headers.set("x-arango-allow-dirty-read", "true"); - } else if (this._loadBalancingStrategy === "ROUND_ROBIN") { - this._activeHostUrl = - this._hostUrls[ - (this._hostUrls.indexOf(this._activeHostUrl) + 1) % - this._hostUrls.length - ]; - } - this._activeTasks += 1; try { - const res = await this._hosts[this._hostUrls.indexOf(hostUrl)]( + this._activeTasks += 1; + if (task.hostUrl !== undefined) { + hostUrl = task.hostUrl; + } else if (task.allowDirtyRead) { + hostUrl = this._activeDirtyHostUrl; + const i = this._hostUrls.indexOf(this._activeDirtyHostUrl) + 1; + this._activeDirtyHostUrl = this._hostUrls[i % this._hostUrls.length]; + } else if (this._loadBalancingStrategy === "ROUND_ROBIN") { + const i = this._hostUrls.indexOf(this._activeHostUrl) + 1; + this._activeHostUrl = this._hostUrls[i % this._hostUrls.length]; + } + const res: globalThis.Response & { + request: globalThis.Request; + arangojsHostUrl: string; + parsedBody?: any; + } = Object.assign(await this._hosts[this._hostUrls.indexOf(hostUrl)]( task.options - ); + ), { arangojsHostUrl: hostUrl }); const leaderEndpoint = res.headers.get(LEADER_ENDPOINT_HEADER); if (res.status === 503 && leaderEndpoint) { const [cleanUrl] = this.addToHostList(leaderEndpoint); @@ -553,62 +576,54 @@ export class Connection { this._activeHostUrl = cleanUrl; } this._queue.push(task); - } else { - res.arangojsHostUrl = hostUrl; - const contentType = res.headers.get("content-type"); - const queueTime = res.headers.get("x-arango-queue-time-seconds"); - if (queueTime) { - this._queueTimes.push([Date.now(), Number(queueTime)]); - while (this._responseQueueTimeSamples < this._queueTimes.length) { - this._queueTimes.shift(); - } + return; + } + const queueTime = res.headers.get("x-arango-queue-time-seconds"); + if (queueTime) { + this._queueTimes.push([Date.now(), Number(queueTime)]); + while (this._responseQueueTimeSamples < this._queueTimes.length) { + this._queueTimes.shift(); } - if (res.status >= 400) { + } + const contentType = res.headers.get("content-type"); + if (res.status >= 400) { + if (contentType?.match(MIME_JSON)) { + const errorResponse = res.clone(); + let errorBody: any; try { - if (contentType?.match(MIME_JSON)) { - const errorResponse = res.clone(); - let errorBody: any; - try { - errorBody = await errorResponse.json(); - } catch { - // noop - } - if (isArangoErrorResponse(errorBody)) { - res.parsedBody = errorBody; - throw new ArangoError(res); - } - } - throw new HttpError(res); - } catch (err: any) { - if (task.stack) { - err.stack += task.stack(); - } - throw err; + errorBody = await errorResponse.json(); + } catch { + // noop } - } - if (res.body) { - if (task.options.expectBinary) { - res.parsedBody = await res.blob(); - } else if (contentType?.match(MIME_JSON)) { - res.parsedBody = await res.json(); - } else { - res.parsedBody = await res.text(); + if (isArangoErrorResponse(errorBody)) { + res.parsedBody = errorBody; + throw ArangoError.from(res); } } - task.resolve(task.transform ? task.transform(res) : res); + throw new HttpError(res); + } + if (res.body) { + if (task.options.expectBinary) { + res.parsedBody = await res.blob(); + } else if (contentType?.match(MIME_JSON)) { + res.parsedBody = await res.json(); + } else { + res.parsedBody = await res.text(); + } } - } catch (err: any) { + let result: any = res; + if (task.transform) result = task.transform(res); + task.resolve(result); + } catch (e: unknown) { + const err = e as Error; if ( !task.allowDirtyRead && this._hosts.length > 1 && this._activeHostUrl === hostUrl && this._loadBalancingStrategy !== "ROUND_ROBIN" ) { - this._activeHostUrl = - this._hostUrls[ - (this._hostUrls.indexOf(this._activeHostUrl) + 1) % - this._hostUrls.length - ]; + const i = this._hostUrls.indexOf(this._activeHostUrl) + 1; + this._activeHostUrl = this._hostUrls[i % this._hostUrls.length]; } if ( isArangoError(err) && @@ -617,28 +632,37 @@ export class Connection { ) { task.retryOnConflict -= 1; this._queue.push(task); - } else if ( - ((isSystemError(err) && - err.syscall === "connect" && - err.code === "ECONNREFUSED") || - (isArangoError(err) && - err.errorNum === ERROR_ARANGO_MAINTENANCE_MODE)) && + return; + } + if ( + (isNetworkError(err) || isArangoError(err)) && + err.isSafeToRetry && task.hostUrl === undefined && this._maxRetries !== false && task.retries < (this._maxRetries || this._hosts.length - 1) ) { task.retries += 1; this._queue.push(task); - } else { - if (task.stack) { - err.stack += task.stack(); + return; + } + if (task.stack) { + err.stack += task.stack(); + } + if (this._onError) { + try { + const p = this._onError(err); + if (p instanceof Promise) await p; + } catch (e) { + (e as Error).cause = err; + task.reject(e); + return; } - task.reject(err); } + task.reject(err); } finally { this._activeTasks -= 1; + setTimeout(() => this._runQueue(), 0); } - this._runQueue(); } setBearerAuth(auth: BearerAuthCredentials) { @@ -832,6 +856,7 @@ export class Connection { const numHosts = this._hosts.length; const propagated = [] as string[]; const started = Date.now(); + const endOfTime = started + timeout; let index = 0; while (true) { if (propagated.length === numHosts) { @@ -842,10 +867,17 @@ export class Connection { } const hostUrl = this._hostUrls[index]; try { - await this.request({ ...request, hostUrl }); - } catch (e: any) { - if (started + timeout < Date.now()) { - throw e; + await this.request({ + ...request, + hostUrl, + timeout: endOfTime - Date.now(), + }); + } catch (e) { + if (endOfTime < Date.now()) { + throw new PropagationTimeoutError( + undefined, + { cause: e as Error } + ); } await new Promise((resolve) => setTimeout(resolve, 1000)); continue; @@ -861,7 +893,7 @@ export class Connection { * * Performs a request using the arangojs connection pool. */ - request( + request( { hostUrl, method = "GET", @@ -876,7 +908,7 @@ export class Connection { path, search: params, }: RequestOptions, - transform?: (res: ArangojsResponse) => T + transform?: (res: globalThis.Response & { request: globalThis.Request; parsedBody?: any }) => T ): Promise { return new Promise((resolve, reject) => { const headers = mergeHeaders(this._headers, requestHeaders ?? {}); @@ -901,6 +933,10 @@ export class Connection { headers.set("x-arango-trx-id", this._transactionId); } + if (allowDirtyRead) { + headers.set("x-arango-allow-dirty-read", "true"); + } + const task: Task = { retries: 0, hostUrl, diff --git a/src/database.ts b/src/database.ts index 5b97c4c7..caef0b42 100644 --- a/src/database.ts +++ b/src/database.ts @@ -29,6 +29,7 @@ import { } from "./collection.js"; import { ArangoApiResponse, + ProcessedResponse, Config, Connection, RequestOptions, @@ -44,7 +45,6 @@ import { } from "./graph.js"; import { Job } from "./job.js"; import { DATABASE_NOT_FOUND } from "./lib/codes.js"; -import { ArangojsResponse } from "./lib/request.js"; import { Route } from "./route.js"; import { Transaction } from "./transaction.js"; import { CreateViewOptions, View, ViewDescription } from "./view.js"; @@ -1758,14 +1758,20 @@ export type LogEntries = { text: string[]; }; +/** + * @internal + */ type TrappedError = { error: true; }; -type TrappedRequest = { +/** + * @internal + */ +type TrappedRequest = { error?: false; jobId: string; - onResolve: (res: ArangojsResponse) => void; + onResolve: (res: ProcessedResponse) => void; onReject: (error: any) => void; }; @@ -1780,7 +1786,9 @@ export class Database { protected _collections = new Map(); protected _graphs = new Map(); protected _views = new Map(); - protected _trapRequest?: (trapped: TrappedError | TrappedRequest) => void; + protected _trapRequest?: ( + trapped: TrappedError | TrappedRequest + ) => void; /** * Creates a new `Database` instance with its own connection pool. @@ -1942,13 +1950,13 @@ export class Database { * ``` */ async createJob(callback: () => Promise): Promise> { - const trap = new Promise((resolveTrap) => { + const trap = new Promise>((resolveTrap) => { this._trapRequest = (trapped) => resolveTrap(trapped); }); const eventualResult = callback(); const trapped = await trap; if (trapped.error) return eventualResult as Promise; - const { jobId, onResolve, onReject } = trapped as TrappedRequest; + const { jobId, onResolve, onReject } = trapped; return new Job( this, jobId, @@ -1976,10 +1984,10 @@ export class Database { * @param transform - An optional function to transform the low-level * response object to a more useful return value. */ - async request( + async request( options: RequestOptions & { absolutePath?: boolean }, - transform?: (res: ArangojsResponse) => T - ): Promise; + transform?: (res: ProcessedResponse) => ReturnType + ): Promise; /** * @internal * @@ -1992,29 +2000,29 @@ export class Database { * @param transform - If set to `false`, the raw response object will be * returned. */ - async request( + async request( options: RequestOptions & { absolutePath?: boolean }, transform: false - ): Promise; - async request( + ): Promise>; + async request( { absolutePath = false, basePath, ...opts }: RequestOptions & { absolutePath?: boolean }, - transform: false | ((res: ArangojsResponse) => T) = (res) => res.parsedBody - ): Promise { + transform: false | ((res: ProcessedResponse) => ReturnType) = (res) => res.parsedBody as ReturnType + ): Promise { if (!absolutePath) { basePath = `/_db/${encodeURIComponent(this._name)}${basePath || ""}`; } if (this._trapRequest) { const trap = this._trapRequest; this._trapRequest = undefined; - return new Promise(async (resolveRequest, rejectRequest) => { + return new Promise(async (resolveRequest, rejectRequest) => { const options = { ...opts }; options.headers = new Headers(options.headers); options.headers.set("x-arango-async", "store"); - let jobRes: ArangojsResponse; + let jobRes: ProcessedResponse; try { jobRes = await this._connection.request({ basePath, ...options }); } catch (e) { @@ -2026,7 +2034,7 @@ export class Database { trap({ jobId, onResolve: (res) => { - const result = transform ? transform(res) : (res as T); + const result = transform ? transform(res) : (res as ReturnType); resolveRequest(result); return result; }, diff --git a/src/error.ts b/src/error.ts index 02e5f539..84bcd72b 100644 --- a/src/error.ts +++ b/src/error.ts @@ -9,7 +9,8 @@ * @packageDocumentation */ -import { ArangojsResponse } from "./lib/request.js"; +import { ProcessedResponse } from "./connection.js"; +import { ERROR_ARANGO_MAINTENANCE_MODE } from "./lib/codes.js"; const messages: { [key: number]: string } = { 0: "Network Error", @@ -58,15 +59,6 @@ const messages: { [key: number]: string } = { 599: "Network Connect Timeout Error", }; -const nativeErrorKeys = [ - "fileName", - "lineNumber", - "columnNumber", - "stack", - "description", - "number", -] as (keyof Error)[]; - /** * Indicates whether the given value represents an {@link ArangoError}. * @@ -77,129 +69,326 @@ export function isArangoError(error: any): error is ArangoError { } /** - * Indicates whether the given value represents an ArangoDB error response. + * Indicates whether the given value represents a {@link NetworkError}. * + * @param error - A value that might be a `NetworkError`. + */ +export function isNetworkError(error: any): error is NetworkError { + return error instanceof NetworkError; +} + +/** * @internal +* + * Indicates whether the given value represents an ArangoDB error response. */ -export function isArangoErrorResponse(body: any): boolean { +export function isArangoErrorResponse(body: any): body is ArangoErrorResponse { return ( body && - body.hasOwnProperty("error") && - body.hasOwnProperty("code") && - body.hasOwnProperty("errorMessage") && - body.hasOwnProperty("errorNum") + body.error === true && + typeof body.code === 'number' && + typeof body.errorMessage === 'string' && + typeof body.errorNum === 'number' ); } /** + * @internal + * * Indicates whether the given value represents a Node.js `SystemError`. */ -export function isSystemError(err: any): err is SystemError { +function isSystemError(err: any): err is SystemError { return ( + err && Object.getPrototypeOf(err) === Error.prototype && - err.hasOwnProperty("code") && - err.hasOwnProperty("errno") && - err.hasOwnProperty("syscall") + typeof err.code === 'string' && + typeof err.errno !== 'undefined' && + typeof err.syscall === 'string' + ); +} + +/** + * @internal + * + * Indicates whether the given value represents a Node.js `UndiciError`. + */ +function isUndiciError(err: any): err is UndiciError { + return ( + err && + err instanceof Error && + typeof (err as UndiciError).code === 'string' && + (err as UndiciError).code.startsWith('UND_') ); } +/** + * @internal + * + * Determines whether the given failed fetch error cause is safe to retry. + */ +function isSafeToRetryFailedFetch(cause: Error): boolean | null { + if (isSystemError(cause) && cause.syscall === 'connect' && cause.code === 'ECONNREFUSED') { + return true; + } + if (isUndiciError(cause) && cause.code === 'UND_ERR_CONNECT_TIMEOUT') { + return true; + } + return null; +} + +/** + * @internal +* + * Interface representing an ArangoDB error response. + */ +export interface ArangoErrorResponse { + error: true; + code: number; + errorMessage: string; + errorNum: number; +} + +/** + * Interface representing a Node.js `UndiciError`. + * + * @internal + */ +interface UndiciError extends Error { + code: `UND_${string}`; +} + /** * Interface representing a Node.js `SystemError`. + * + * @internal */ -export interface SystemError extends Error { +interface SystemError extends Error { code: string; errno: number | string; syscall: string; } /** - * Represents an error returned by ArangoDB. + * Represents an error from a deliberate timeout encountered while waiting + * for propagation. */ -export class ArangoError extends Error { - name = "ArangoError"; +export class PropagationTimeoutError extends Error { + name = "PropagationTimeoutError"; + + constructor(message: string | undefined, options: { cause: Error }) { + super(message ?? 'Timed out while waiting for propagation', options); + } +} + +/** + * Represents a network error or an error encountered while performing a network request. + */ +export class NetworkError extends Error { + name = "NetworkError"; + /** - * ArangoDB error code. - * - * See [ArangoDB error documentation](https://www.arangodb.com/docs/stable/appendix-error-codes.html). + * Indicates whether the request that caused this error can be safely retried. */ - errorNum: number; + isSafeToRetry: boolean | null; + /** - * HTTP status code included in the server error response object. + * Fetch request object. */ - code: number; + request: globalThis.Request; + + constructor(message: string, options: { request: globalThis.Request, cause?: Error, isSafeToRetry?: boolean | null }) { + const { request, isSafeToRetry = null, ...opts } = options; + super(message, opts); + this.request = request; + this.isSafeToRetry = isSafeToRetry; + } + + toJSON() { + return { + error: true, + errorMessage: this.message, + code: 0, + }; + } +} + +/** + * Represents an error from a deliberate timeout encountered while waiting + * for a server response. + */ +export class ResponseTimeoutError extends NetworkError { + name = "ResponseTimeoutError"; + + constructor(message: string | undefined, options: { request: globalThis.Request, cause?: Error, isSafeToRetry?: boolean | null }) { + super(message ?? 'Timed out while waiting for server response', options); + } +} + +/** + * Represents an error from a request that was aborted. + */ +export class RequestAbortedError extends NetworkError { + name = "RequestAbortedError"; + + constructor(message: string | undefined, options: { request: globalThis.Request, cause?: Error, isSafeToRetry?: boolean | null }) { + super(message ?? 'Request aborted', options); + } +} + +/** + * Represents an error from a failed fetch request. + * + * The root cause is often extremely difficult to determine. + */ +export class FetchFailedError extends NetworkError { + name = "FetchFailedError"; + + constructor(message: string | undefined, options: { request: globalThis.Request, cause: TypeError, isSafeToRetry?: boolean | null }) { + let isSafeToRetry = options.isSafeToRetry; + if (options.cause.cause instanceof Error) { + if (isSafeToRetry === undefined) { + isSafeToRetry = isSafeToRetryFailedFetch(options.cause.cause) || undefined; + } + if (message === undefined) { + message = `Fetch failed: ${options.cause.cause.message}`; + } + } + super(message ?? 'Fetch failed', { ...options, isSafeToRetry }); + } +} + +/** + * Represents a plain HTTP error response. + */ +export class HttpError extends NetworkError { + name = "HttpError"; + /** - * Server response object. + * HTTP status code of the server response. */ - response: any; + code: number; /** - * @internal + * Server response object. */ - constructor(response: ArangojsResponse) { - super(); - this.response = response; - this.message = response.parsedBody.errorMessage; - this.errorNum = response.parsedBody.errorNum; - this.code = response.parsedBody.code; - const err = new Error(this.message); - err.name = this.name; - for (const key of nativeErrorKeys) { - if (err[key]) this[key] = err[key] as string; - } - } + response: ProcessedResponse; /** * @internal - * - * Indicates that this object represents an ArangoDB error. */ - get isArangoError(): true { - return true; + constructor(response: ProcessedResponse, options: { cause?: Error, isSafeToRetry?: boolean | null } = {}) { + const message = messages[response.status] ?? messages[500]; + super(message, { ...options, request: response.request }); + this.response = response; + this.code = response.status; } toJSON() { return { error: true, errorMessage: this.message, - errorNum: this.errorNum, code: this.code, }; } } /** - * Represents a plain HTTP error response. + * Represents an error returned by ArangoDB. */ -export class HttpError extends Error { - name = "HttpError"; +export class ArangoError extends Error { + name = "ArangoError"; + /** - * Server response object. + * Indicates whether the request that caused this error can be safely retried. + * + * @internal */ - response: any; + isSafeToRetry: boolean | null = null; + /** - * HTTP status code of the server response. + * ArangoDB error code. + * + * See [ArangoDB error documentation](https://www.arangodb.com/docs/stable/appendix-error-codes.html). + */ + errorNum: number; + + /** + * Error message accompanying the error code. + */ + get errorMessage(): string { + return this.message; + } + + /** + * HTTP status code included in the server error response object. */ code: number; /** * @internal + * + * Creates a new `ArangoError` from a response object. */ - constructor(response: ArangojsResponse) { - super(); - this.response = response; - this.code = response.status || 500; - this.message = messages[this.code] || messages[500]; - const err = new Error(this.message); - err.name = this.name; - for (const key of nativeErrorKeys) { - if (err[key]) this[key] = err[key] as string; + static from(response: ProcessedResponse): ArangoError { + return new ArangoError(response.parsedBody!, { + cause: new HttpError(response) + }); + } + + /** + * Creates a new `ArangoError` from an ArangoDB error response. + */ + constructor(data: ArangoErrorResponse, options: { cause?: Error, isSafeToRetry?: boolean | null }) { + const { isSafeToRetry, ...opts } = options; + super(data.errorMessage, opts); + this.errorNum = data.errorNum; + this.code = data.code; + if (isSafeToRetry !== undefined) { + this.isSafeToRetry = isSafeToRetry; + } else if (this.errorNum === ERROR_ARANGO_MAINTENANCE_MODE) { + this.isSafeToRetry = true; + } else if (this.cause instanceof NetworkError) { + this.isSafeToRetry = this.cause.isSafeToRetry; } } - toJSON() { + /** + * Server response object. + */ + get response(): ProcessedResponse | undefined { + const cause = this.cause; + if (cause instanceof HttpError) { + return cause.response; + } + return undefined; + } + + /** + * Fetch request object. + */ + get request(): globalThis.Request | undefined { + const cause = this.cause; + if (cause instanceof NetworkError) { + return cause.request; + } + return undefined; + } + + /** + * @internal + * + * Indicates that this object represents an ArangoDB error. + */ + get isArangoError(): true { + return true; + } + + toJSON(): ArangoErrorResponse { return { error: true, + errorMessage: this.errorMessage, + errorNum: this.errorNum, code: this.code, }; } -} +} \ No newline at end of file diff --git a/src/job.ts b/src/job.ts index 86e53ab6..92c2ada7 100644 --- a/src/job.ts +++ b/src/job.ts @@ -1,5 +1,5 @@ +import { ProcessedResponse } from "./connection.js"; import { Database } from "./database.js"; -import { ArangojsResponse } from "./lib/request.js"; /** * Represents an async job in a {@link database.Database}. @@ -7,7 +7,7 @@ import { ArangojsResponse } from "./lib/request.js"; export class Job { protected _id: string; protected _db: Database; - protected _transformResponse?: (res: ArangojsResponse) => Promise; + protected _transformResponse?: (res: ProcessedResponse) => Promise; protected _transformError?: (error: any) => Promise; protected _loaded: boolean = false; protected _result: T | undefined; @@ -18,7 +18,7 @@ export class Job { constructor( db: Database, id: string, - transformResponse?: (res: ArangojsResponse) => Promise, + transformResponse?: (res: ProcessedResponse) => Promise, transformError?: (error: any) => Promise ) { this._db = db; @@ -73,7 +73,7 @@ export class Job { */ async load(): Promise { if (!this.isLoaded) { - let res: ArangojsResponse; + let res: ProcessedResponse; try { res = await this._db.request( { diff --git a/src/lib/request.ts b/src/lib/request.ts index 0b421ac8..50daed6d 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -5,36 +5,14 @@ * @internal */ -import { SystemError } from "../error.js"; +import { FetchFailedError, NetworkError, RequestAbortedError, ResponseTimeoutError } from "../error.js"; -/** - * @internal - */ -function systemErrorToJSON(this: SystemError) { - return { - error: true, - errno: this.errno, - code: this.code, - syscall: this.syscall, - }; -} - -/** - * @internal - */ -export interface ArangojsResponse extends globalThis.Response { - request: globalThis.Request; - parsedBody?: any; - arangojsHostUrl?: string; +function timer(timeout: number, cb: () => void) { + const t = setTimeout(cb, timeout); + return () => clearTimeout(t); } -/** - * @internal - */ -export interface ArangojsError extends Error { - request: globalThis.Request; - toJSON: () => Record; -} +export const REASON_TIMEOUT = 'timeout'; /** * @internal @@ -55,15 +33,15 @@ export type RequestOptions = { export type RequestConfig = { credentials: "omit" | "include" | "same-origin"; keepalive: boolean; - beforeRequest?: (req: globalThis.Request) => void; - afterResponse?: (err: ArangojsError | null, res?: ArangojsResponse) => void; + beforeRequest?: (req: globalThis.Request) => void | Promise; + afterResponse?: (err: NetworkError | null, res?: globalThis.Response & { request: globalThis.Request }) => void | Promise; }; /** * @internal */ export type RequestFunction = { - (options: RequestOptions): Promise; + (options: RequestOptions): Promise; close?: () => void; }; @@ -85,7 +63,7 @@ export function createRequest( baseUrl: URL, config: RequestConfig ): RequestFunction { - let abort: AbortController | undefined; + let abort: () => void | undefined; return Object.assign( async function request({ method, @@ -128,38 +106,54 @@ export function createRequest( keepalive: config.keepalive, }); if (config.beforeRequest) { - config.beforeRequest(request); + const p = config.beforeRequest(request); + if (p instanceof Promise) await p; } - abort = new AbortController(); - let t: ReturnType | undefined; + const abortController = new AbortController(); + const signal = abortController.signal; + abort = () => abortController.abort(); + let clearTimer: (() => void) | undefined; if (timeout) { - t = setTimeout(() => { - abort?.abort(); - }, timeout); + clearTimer = timer(timeout, () => { + clearTimer = undefined; + abortController.abort(REASON_TIMEOUT); + }); } + let response: globalThis.Response & { request: globalThis.Request }; try { - const res = await fetch(request, { signal: abort.signal }); - if (t) clearTimeout(t); - const response = res as ArangojsResponse; - response.request = request; - if (config.afterResponse) { - config.afterResponse(null, response); + response = Object.assign(await fetch(request, { signal }), { request }); + } catch (e: unknown) { + const cause = e instanceof Error ? e : new Error(String(e)); + let error: NetworkError; + if (signal.aborted) { + const reason = typeof signal.reason == 'string' ? signal.reason : undefined; + if (reason === REASON_TIMEOUT) { + error = new ResponseTimeoutError(undefined, { request }); + } else { + error = new RequestAbortedError(reason, { request, cause }); + } + } else if (cause instanceof TypeError) { + error = new FetchFailedError(undefined, { request, cause }); + } else { + error = new NetworkError(cause.message, { request, cause }); } - return response; - } catch (err) { - if (t) clearTimeout(t); - const error = err as ArangojsError; - error.request = request; - error.toJSON = systemErrorToJSON; if (config.afterResponse) { - config.afterResponse(error); + const p = config.afterResponse(error); + if (p instanceof Promise) await p; } throw error; + } finally { + clearTimer?.(); + } + if (config.afterResponse) { + const p = config.afterResponse(null, response); + if (p instanceof Promise) await p; } + return response; }, { close() { - abort?.abort(); + abort?.(); }, } ); diff --git a/src/route.ts b/src/route.ts index fef1954a..fe52bfce 100644 --- a/src/route.ts +++ b/src/route.ts @@ -7,9 +7,8 @@ * * @packageDocumentation */ -import { RequestOptions } from "./connection.js"; +import { ProcessedResponse, RequestOptions } from "./connection.js"; import { Database } from "./database.js"; -import { ArangojsResponse } from "./lib/request.js"; import { mergeHeaders } from "./lib/mergeHeaders.js"; /** @@ -130,7 +129,7 @@ export class Route { path: string, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; + ): Promise; /** * Performs a DELETE request against the given path relative to this route * and returns the server response. @@ -149,8 +148,8 @@ export class Route { delete( search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; - delete(...args: any[]): Promise { + ): Promise; + delete(...args: any[]): Promise { const path = typeof args[0] === "string" ? args.shift() : undefined; const [search, headers] = args; return this.request({ method: "DELETE", path, search, headers }); @@ -175,7 +174,7 @@ export class Route { path: string, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; + ): Promise; /** * Performs a GET request against the given path relative to this route * and returns the server response. @@ -194,8 +193,8 @@ export class Route { get( search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; - get(...args: any[]): Promise { + ): Promise; + get(...args: any[]): Promise { const path = typeof args[0] === "string" ? args.shift() : undefined; const [search, headers] = args; return this.request({ method: "GET", path, search, headers }); @@ -220,7 +219,7 @@ export class Route { path: string, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; + ): Promise; /** * Performs a HEAD request against the given path relative to this route * and returns the server response. @@ -239,8 +238,8 @@ export class Route { head( search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; - head(...args: any[]): Promise { + ): Promise; + head(...args: any[]): Promise { const path = typeof args[0] === "string" ? args.shift() : undefined; const [search, headers] = args; return this.request({ method: "HEAD", path, search, headers }); @@ -267,7 +266,7 @@ export class Route { body?: any, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; + ): Promise; /** * Performs a PATCH request against the given path relative to this route * and returns the server response. @@ -290,8 +289,8 @@ export class Route { body?: any, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; - patch(...args: any[]): Promise { + ): Promise; + patch(...args: any[]): Promise { const path = typeof args[0] === "string" ? args.shift() : undefined; const [body, search, headers] = args; return this.request({ method: "PATCH", path, body, search, headers }); @@ -321,7 +320,7 @@ export class Route { body?: any, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; + ): Promise; /** * Performs a POST request against the given path relative to this route * and returns the server response. @@ -347,8 +346,8 @@ export class Route { body?: any, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; - post(...args: any[]): Promise { + ): Promise; + post(...args: any[]): Promise { const path = typeof args[0] === "string" ? args.shift() : undefined; const [body, search, headers] = args; return this.request({ method: "POST", path, body, search, headers }); @@ -375,7 +374,7 @@ export class Route { body?: any, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; + ): Promise; /** * Performs a PUT request against the given path relative to this route * and returns the server response. @@ -398,8 +397,8 @@ export class Route { body?: any, search?: URLSearchParams | Record, headers?: Headers | Record - ): Promise; - put(...args: any[]): Promise { + ): Promise; + put(...args: any[]): Promise { const path = typeof args[0] === "string" ? args.shift() : undefined; const [body, search, headers] = args; return this.request({ method: "PUT", path, body, search, headers }); diff --git a/src/test/27-query-management.ts b/src/test/27-query-management.ts index b1e6bda2..8cf1b3d9 100644 --- a/src/test/27-query-management.ts +++ b/src/test/27-query-management.ts @@ -2,7 +2,7 @@ import { expect } from "chai"; import { aql } from "../aql.js"; import { ArrayCursor } from "../cursor.js"; import { Database } from "../database.js"; -import { ArangoError } from "../error.js"; +import { ArangoError, ResponseTimeoutError } from "../error.js"; import { config } from "./_config.js"; // NOTE These tests will not reliably work with load balancing. @@ -67,7 +67,7 @@ describe("Query Management API", function () { } catch (err: any) { expect(err).is.instanceof(Error); expect(err).is.not.instanceof(ArangoError); - expect(err.name).to.equal("AbortError"); + expect(err).is.instanceof(ResponseTimeoutError); return; } expect.fail();