diff --git a/__tests__/integration/stream.test.ts b/__tests__/integration/stream.test.ts index 7daa1a65..11b6a5b2 100644 --- a/__tests__/integration/stream.test.ts +++ b/__tests__/integration/stream.test.ts @@ -289,7 +289,7 @@ describe("StreamClient", () => { } } catch (e: any) { expect(e).toBeInstanceOf(AbortError); - expect(e.httpStatus).toBe(-1); + expect(e.httpStatus).toBeUndefined(); expect(e.abort).toBe("oops"); } finally { stream?.close(); @@ -316,7 +316,7 @@ describe("StreamClient", () => { } } catch (e: any) { expect(e).toBeInstanceOf(QueryRuntimeError); - expect(e.httpStatus).toBe(-1); + expect(e.httpStatus).toBeUndefined(); } finally { stream?.close(); } @@ -347,7 +347,7 @@ describe("StreamClient", () => { function onEvent(_) {}, function onError(e) { if (e instanceof AbortError) { - expect(e.httpStatus).toBe(-1); + expect(e.httpStatus).toBeUndefined(); expect(e.abort).toBe("oops"); } resolve(); @@ -382,7 +382,7 @@ describe("StreamClient", () => { function onEvent(_) {}, function onError(e) { if (e instanceof QueryRuntimeError) { - expect(e.httpStatus).toBe(-1); + expect(e.httpStatus).toBeUndefined(); } resolve(); }, diff --git a/__tests__/unit/error.test.ts b/__tests__/unit/error.test.ts index 681d0d8a..370b565b 100644 --- a/__tests__/unit/error.test.ts +++ b/__tests__/unit/error.test.ts @@ -2,71 +2,74 @@ import { AbortError, AuthenticationError, AuthorizationError, + ConstraintFailureError, ContendedTransactionError, InvalidRequestError, QueryCheckError, QueryFailure, QueryRuntimeError, QueryTimeoutError, - ServiceError, ServiceInternalError, - ServiceTimeoutError, ThrottlingError, } from "../../src"; import { getServiceError } from "../../src/errors"; describe("query", () => { it.each` - httpStatus | code | errorClass - ${400} | ${"invalid_function_definition"} | ${QueryCheckError} - ${400} | ${"invalid_identifier"} | ${QueryCheckError} - ${400} | ${"invalid_query"} | ${QueryCheckError} - ${400} | ${"invalid_syntax"} | ${QueryCheckError} - ${400} | ${"invalid_type"} | ${QueryCheckError} - ${400} | ${"unbound_variable"} | ${QueryRuntimeError} - ${400} | ${"index_out_of_bounds"} | ${QueryRuntimeError} - ${400} | ${"type_mismatch"} | ${QueryRuntimeError} - ${400} | ${"invalid_argument"} | ${QueryRuntimeError} - ${400} | ${"invalid_bounds"} | ${QueryRuntimeError} - ${400} | ${"invalid_regex"} | ${QueryRuntimeError} - ${400} | ${"constraint_failure"} | ${QueryRuntimeError} - ${400} | ${"invalid_schema"} | ${QueryRuntimeError} - ${400} | ${"invalid_document_id"} | ${QueryRuntimeError} - ${400} | ${"document_id_exists"} | ${QueryRuntimeError} - ${400} | ${"document_not_found"} | ${QueryRuntimeError} - ${400} | ${"document_deleted"} | ${QueryRuntimeError} - ${400} | ${"invalid_function_invocation"} | ${QueryRuntimeError} - ${400} | ${"invalid_index_invocation"} | ${QueryRuntimeError} - ${400} | ${"null_value"} | ${QueryRuntimeError} - ${400} | ${"invalid_null_access"} | ${QueryRuntimeError} - ${400} | ${"invalid_cursor"} | ${QueryRuntimeError} - ${400} | ${"permission_denied"} | ${QueryRuntimeError} - ${400} | ${"invalid_effect"} | ${QueryRuntimeError} - ${400} | ${"invalid_write"} | ${QueryRuntimeError} - ${400} | ${"internal_failure"} | ${QueryRuntimeError} - ${400} | ${"divide_by_zero"} | ${QueryRuntimeError} - ${400} | ${"invalid_id"} | ${QueryRuntimeError} - ${400} | ${"invalid_secret"} | ${QueryRuntimeError} - ${400} | ${"invalid_time"} | ${QueryRuntimeError} - ${400} | ${"invalid_unit"} | ${QueryRuntimeError} - ${400} | ${"invalid_date"} | ${QueryRuntimeError} - ${400} | ${"limit_exceeded"} | ${QueryRuntimeError} - ${400} | ${"stack_overflow"} | ${QueryRuntimeError} - ${400} | ${"invalid_computed_field_access"} | ${QueryRuntimeError} - ${400} | ${"disabled_feature"} | ${QueryRuntimeError} - ${400} | ${"invalid_receiver"} | ${QueryRuntimeError} - ${400} | ${"invalid_timestamp_field_access"} | ${QueryRuntimeError} - ${400} | ${"invalid_request"} | ${InvalidRequestError} - ${400} | ${"abort"} | ${AbortError} - ${401} | ${"unauthorized"} | ${AuthenticationError} - ${403} | ${"forbidden"} | ${AuthorizationError} - ${409} | ${"contended_transaction"} | ${ContendedTransactionError} - ${429} | ${"throttle"} | ${ThrottlingError} - ${440} | ${"time_out"} | ${QueryTimeoutError} - ${503} | ${"time_out"} | ${ServiceTimeoutError} - ${500} | ${"internal_error"} | ${ServiceInternalError} - ${999} | ${"error_not_yet_subclassed_in_client"} | ${ServiceError} - ${-1} | ${"error_not_yet_subclassed_in_client"} | ${ServiceError} + httpStatus | code | errorClass + ${400} | ${"invalid_query"} | ${QueryCheckError} + ${400} | ${"unbound_variable"} | ${QueryRuntimeError} + ${400} | ${"index_out_of_bounds"} | ${QueryRuntimeError} + ${400} | ${"type_mismatch"} | ${QueryRuntimeError} + ${400} | ${"invalid_argument"} | ${QueryRuntimeError} + ${400} | ${"invalid_bounds"} | ${QueryRuntimeError} + ${400} | ${"invalid_regex"} | ${QueryRuntimeError} + ${400} | ${"invalid_schema"} | ${QueryRuntimeError} + ${400} | ${"invalid_document_id"} | ${QueryRuntimeError} + ${400} | ${"document_id_exists"} | ${QueryRuntimeError} + ${400} | ${"document_not_found"} | ${QueryRuntimeError} + ${400} | ${"document_deleted"} | ${QueryRuntimeError} + ${400} | ${"invalid_function_invocation"} | ${QueryRuntimeError} + ${400} | ${"invalid_index_invocation"} | ${QueryRuntimeError} + ${400} | ${"null_value"} | ${QueryRuntimeError} + ${400} | ${"invalid_null_access"} | ${QueryRuntimeError} + ${400} | ${"invalid_cursor"} | ${QueryRuntimeError} + ${400} | ${"permission_denied"} | ${QueryRuntimeError} + ${400} | ${"invalid_effect"} | ${QueryRuntimeError} + ${400} | ${"invalid_write"} | ${QueryRuntimeError} + ${400} | ${"internal_failure"} | ${QueryRuntimeError} + ${400} | ${"divide_by_zero"} | ${QueryRuntimeError} + ${400} | ${"invalid_id"} | ${QueryRuntimeError} + ${400} | ${"invalid_secret"} | ${QueryRuntimeError} + ${400} | ${"invalid_time"} | ${QueryRuntimeError} + ${400} | ${"invalid_unit"} | ${QueryRuntimeError} + ${400} | ${"invalid_date"} | ${QueryRuntimeError} + ${400} | ${"limit_exceeded"} | ${QueryRuntimeError} + ${400} | ${"stack_overflow"} | ${QueryRuntimeError} + ${400} | ${"invalid_computed_field_access"} | ${QueryRuntimeError} + ${400} | ${"disabled_feature"} | ${QueryRuntimeError} + ${400} | ${"invalid_receiver"} | ${QueryRuntimeError} + ${400} | ${"invalid_timestamp_field_access"} | ${QueryRuntimeError} + ${400} | ${"invalid_request"} | ${InvalidRequestError} + ${400} | ${"abort"} | ${AbortError} + ${400} | ${"constraint_failure"} | ${ConstraintFailureError} + ${401} | ${"unauthorized"} | ${AuthenticationError} + ${403} | ${"forbidden"} | ${AuthorizationError} + ${409} | ${"contended_transaction"} | ${ContendedTransactionError} + ${429} | ${"throttle"} | ${ThrottlingError} + ${440} | ${"time_out"} | ${QueryTimeoutError} + ${503} | ${"time_out"} | ${QueryTimeoutError} + ${500} | ${"internal_error"} | ${ServiceInternalError} + ${400} | ${"some unhandled code"} | ${QueryRuntimeError} + ${401} | ${"some unhandled code"} | ${QueryRuntimeError} + ${403} | ${"some unhandled code"} | ${QueryRuntimeError} + ${409} | ${"some unhandled code"} | ${QueryRuntimeError} + ${429} | ${"some unhandled code"} | ${QueryRuntimeError} + ${440} | ${"some unhandled code"} | ${QueryRuntimeError} + ${500} | ${"some unhandled code"} | ${QueryRuntimeError} + ${503} | ${"some unhandled code"} | ${QueryRuntimeError} + ${999} | ${"some unhandled code"} | ${QueryRuntimeError} + ${undefined} | ${"some unhandled code"} | ${QueryRuntimeError} `( "QueryFailures with status '$httpStatus' and code '$code' are correctly mapped to $errorClass", ({ httpStatus, code, errorClass }) => { @@ -75,14 +78,19 @@ describe("query", () => { message: "error message", code, abort: "oops", + constraint_failures: [{ message: "oops" }], }, }; const error = getServiceError(failure, httpStatus); - expect(error).toBeInstanceOf(errorClass); expect(error.httpStatus).toEqual(httpStatus); expect(error.code).toEqual(code); + + const error_no_status = getServiceError(failure); + expect(error_no_status).toBeInstanceOf(errorClass); + expect(error_no_status.httpStatus).toBeUndefined(); + expect(error_no_status.code).toEqual(code); }, ); }); diff --git a/__tests__/unit/query.test.ts b/__tests__/unit/query.test.ts index dc586205..7d80bb74 100644 --- a/__tests__/unit/query.test.ts +++ b/__tests__/unit/query.test.ts @@ -8,7 +8,6 @@ import { QueryTimeoutError, ServiceError, ServiceInternalError, - ServiceTimeoutError, ThrottlingError, } from "../../src"; import { getClient, getDefaultHTTPClientOptions } from "../client"; @@ -38,7 +37,7 @@ describe("query", () => { ${999} | ${ServiceError} | ${{ code: "error_not_yet_subclassed_in_client", message: "who knows!!!" }} ${429} | ${ThrottlingError} | ${{ code: "throttle", message: "too much" }} ${500} | ${ServiceInternalError} | ${{ code: "internal_error", message: "unexpected error" }} - ${503} | ${ServiceTimeoutError} | ${{ code: "time_out", message: "too slow on our side" }} + ${503} | ${QueryTimeoutError} | ${{ code: "time_out", message: "too slow on our side" }} `( "throws an $expectedErrorType on a $httpStatus", async ({ httpStatus, expectedErrorType, expectedErrorFields }) => { @@ -67,7 +66,7 @@ describe("query", () => { ${999} | ${ServiceError} | ${{ code: "error_not_yet_subclassed_in_client", message: "who knows!!!" }} ${429} | ${ThrottlingError} | ${{ code: "throttle", message: "too much" }} ${500} | ${ServiceInternalError} | ${{ code: "internal_error", message: "unexpected error" }} - ${503} | ${ServiceTimeoutError} | ${{ code: "time_out", message: "too slow on our side" }} + ${503} | ${QueryTimeoutError} | ${{ code: "time_out", message: "too slow on our side" }} `( "Includes a summary when not present in error field but present at top-level", async ({ httpStatus, expectedErrorType, expectedErrorFields }) => { diff --git a/src/client.ts b/src/client.ts index 470ed0c1..986e8e49 100644 --- a/src/client.ts +++ b/src/client.ts @@ -791,7 +791,7 @@ export class StreamClient { if (deserializedEvent.type === "error") { // Errors sent from Fauna are assumed fatal this.close(); - throw getServiceError(deserializedEvent, -1); + throw getServiceError(deserializedEvent); } this.#last_ts = deserializedEvent.txn_ts; diff --git a/src/errors.ts b/src/errors.ts index c3f09cd7..d9b062bc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -21,7 +21,7 @@ export class ServiceError extends FaunaError { /** * The HTTP Status Code of the error. */ - readonly httpStatus: number; + readonly httpStatus?: number; /** * A code for the error. Codes indicate the cause of the error. * It is safe to write programmatic logic against the code. They are @@ -38,7 +38,7 @@ export class ServiceError extends FaunaError { */ readonly constraint_failures?: Array; - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure.error.message); // Maintains proper stack trace for where our error was thrown (only available on V8) @@ -71,7 +71,7 @@ export class ServiceError extends FaunaError { * @see {@link https://fqlx-beta--fauna-docs.netlify.app/fqlx/beta/reference/language/errors#runtime-errors} */ export class QueryRuntimeError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryRuntimeError); @@ -89,7 +89,7 @@ export class QueryRuntimeError extends ServiceError { * @see {@link https://fqlx-beta--fauna-docs.netlify.app/fqlx/beta/reference/language/errors#runtime-errors} */ export class QueryCheckError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryCheckError); @@ -105,7 +105,7 @@ export class QueryCheckError extends ServiceError { * @see {@link https://fqlx-beta--fauna-docs.netlify.app/fqlx/beta/reference/language/errors#runtime-errors} */ export class InvalidRequestError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, InvalidRequestError); @@ -114,6 +114,34 @@ export class InvalidRequestError extends ServiceError { } } +/** + * A runtime error due to failing schema constraints. + * + * @see {@link https://fqlx-beta--fauna-docs.netlify.app/fqlx/beta/reference/language/errors#runtime-errors} + */ +export class ConstraintFailureError extends ServiceError { + /** + * The user provided value passed to the originating `abort()` call. + * Present only when the query encountered an `abort()` call, which is denoted + * by the error code `"abort"` + */ + readonly constraint_failures: Array; + + constructor( + failure: QueryFailure & { + error: { constraint_failures: Array }; + }, + httpStatus?: number, + ) { + super(failure, httpStatus); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, QueryCheckError); + } + this.name = "ConstraintFailureError"; + this.constraint_failures = failure.error.constraint_failures; + } +} + /** * An error due to calling the FQL `abort` function. * @@ -129,7 +157,7 @@ export class AbortError extends ServiceError { constructor( failure: QueryFailure & { error: { abort: QueryValue } }, - httpStatus: number, + httpStatus?: number, ) { super(failure, httpStatus); if (Error.captureStackTrace) { @@ -145,7 +173,7 @@ export class AbortError extends ServiceError { * used. */ export class AuthenticationError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, AuthenticationError); @@ -159,7 +187,7 @@ export class AuthenticationError extends ServiceError { * permission to perform the requested action. */ export class AuthorizationError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, AuthorizationError); @@ -172,7 +200,7 @@ export class AuthorizationError extends ServiceError { * An error due to a contended transaction. */ export class ContendedTransactionError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, InvalidRequestError); @@ -186,7 +214,7 @@ export class ContendedTransactionError extends ServiceError { * and thus the request could not be served. */ export class ThrottlingError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, ThrottlingError); @@ -196,11 +224,16 @@ export class ThrottlingError extends ServiceError { } /** - * A failure due to the timeout being exceeded, but the timeout - * was set lower than the query's expected processing time. - * This response is distinguished from a ServiceTimeoutException - * in that a QueryTimeoutError shows Fauna behaving in an expected - * manner. + * A failure due to the query timeout being exceeded. + * + * This error can have one of two sources: + * 1. Fauna is behaving expectedly, but the query timeout provided was too + * aggressive and lower than the query's expected processing time. + * 2. Fauna was not available to service the request before the timeout was + * reached. + * + * In either case, consider increasing the `query_timeout_ms` configuration for + * your client. */ export class QueryTimeoutError extends ServiceError { /** @@ -208,7 +241,7 @@ export class QueryTimeoutError extends ServiceError { */ readonly stats?: { [key: string]: number }; - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryTimeoutError); @@ -222,7 +255,7 @@ export class QueryTimeoutError extends ServiceError { * ServiceInternalError indicates Fauna failed unexpectedly. */ export class ServiceInternalError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, ServiceInternalError); @@ -231,20 +264,6 @@ export class ServiceInternalError extends ServiceError { } } -/** - * ServiceTimeoutError indicates Fauna was not available to servce - * the request before the timeout was reached. - */ -export class ServiceTimeoutError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: number) { - super(failure, httpStatus); - if (Error.captureStackTrace) { - Error.captureStackTrace(this, ServiceTimeoutError); - } - this.name = "ServiceTimeoutError"; - } -} - /** * An error representing a failure internal to the client, itself. * This indicates Fauna was never called - the client failed internally @@ -315,53 +334,14 @@ export class ProtocolError extends FaunaError { export const getServiceError = ( failure: QueryFailure, - httpStatus: number, + httpStatus?: number, ): ServiceError => { const failureCode = failure.error.code; switch (failureCode) { - case "invalid_function_definition": - case "invalid_identifier": case "invalid_query": - case "invalid_syntax": - case "invalid_type": return new QueryCheckError(failure, httpStatus); - case "unbound_variable": - case "index_out_of_bounds": - case "type_mismatch": - case "invalid_argument": - case "invalid_bounds": - case "invalid_regex": - case "constraint_failure": - case "invalid_schema": - case "invalid_document_id": - case "document_id_exists": - case "document_not_found": - case "document_deleted": - case "invalid_function_invocation": - case "invalid_index_invocation": - case "null_value": - case "invalid_null_access": - case "invalid_cursor": - case "permission_denied": - case "invalid_effect": - case "invalid_write": - case "internal_failure": - case "divide_by_zero": - case "invalid_id": - case "invalid_secret": - case "invalid_time": - case "invalid_unit": - case "invalid_date": - case "limit_exceeded": - case "stack_overflow": - case "invalid_computed_field_access": - case "disabled_feature": - case "invalid_receiver": - case "invalid_timestamp_field_access": - return new QueryRuntimeError(failure, httpStatus); - case "invalid_request": return new InvalidRequestError(failure, httpStatus); @@ -372,7 +352,18 @@ export const getServiceError = ( httpStatus, ); } - return new QueryRuntimeError(failure, httpStatus); + break; + + case "constraint_failure": + if (failure.error.constraint_failures !== undefined) { + return new ConstraintFailureError( + failure as QueryFailure & { + error: { constraint_failures: Array }; + }, + httpStatus, + ); + } + break; case "unauthorized": return new AuthenticationError(failure, httpStatus); @@ -387,17 +378,11 @@ export const getServiceError = ( return new ThrottlingError(failure, httpStatus); case "time_out": - if (httpStatus === 440) { - return new QueryTimeoutError(failure, 440); - } else if (httpStatus === 503) { - return new ServiceTimeoutError(failure, 503); - } - break; + return new QueryTimeoutError(failure, httpStatus); case "internal_error": return new ServiceInternalError(failure, httpStatus); } - // default - return new ServiceError(failure, httpStatus ?? -1); + return new QueryRuntimeError(failure, httpStatus); }; diff --git a/src/index.ts b/src/index.ts index 808a0e4e..95303bf6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { AuthorizationError, ClientError, ClientClosedError, + ConstraintFailureError, ContendedTransactionError, FaunaError, InvalidRequestError, @@ -21,7 +22,6 @@ export { QueryTimeoutError, ServiceError, ServiceInternalError, - ServiceTimeoutError, ThrottlingError, } from "./errors"; export { type Query, fql } from "./query-builder";