diff --git a/src/Errors.ts b/src/Errors.ts index 8bc8284..222e0ad 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -1,94 +1,137 @@ +// Each class sets `name` to an explicit string (rather than relying on +// constructor.name) so the value survives minification and works across +// duplicate copies of this package, where instanceof checks can fail. export class SmartyError extends Error { constructor(message: string = "unexpected error") { super(message); + this.name = "SmartyError"; } } export class DefaultError extends SmartyError { constructor(message?: string | null) { super(message || "unexpected error"); + this.name = "DefaultError"; } } export class BatchFullError extends SmartyError { constructor() { super("A batch can contain a max of 100 lookups."); + this.name = "BatchFullError"; } } export class BatchEmptyError extends SmartyError { constructor() { super("A batch must contain at least 1 lookup."); + this.name = "BatchEmptyError"; } } export class UndefinedLookupError extends SmartyError { constructor() { super("The lookup provided is missing or undefined. Make sure you're passing a Lookup object."); + this.name = "UndefinedLookupError"; } } export class BadCredentialsError extends SmartyError { - constructor() { + constructor(message?: string) { super( - "Unauthorized: The credentials were provided incorrectly or did not match any existing active credentials.", + message ?? + "Unauthorized: The credentials were provided incorrectly or did not match any existing, active credentials.", ); + this.name = "BadCredentialsError"; } } export class PaymentRequiredError extends SmartyError { - constructor() { + constructor(message?: string) { + super( + message ?? + "Payment Required: There is no active subscription for the account associated with the credentials submitted with the request.", + ); + this.name = "PaymentRequiredError"; + } +} + +export class ForbiddenError extends SmartyError { + constructor(message?: string) { super( - "Payment Required: There is no active subscription for the account associated with the credentials submitted with the request.", + message ?? + "Forbidden: The request contained valid data and was understood by the server, but the server is refusing action.", ); + this.name = "ForbiddenError"; + } +} + +export class RequestTimeoutError extends SmartyError { + constructor(message?: string) { + super(message ?? "Request timeout error."); + this.name = "RequestTimeoutError"; } } export class RequestEntityTooLargeError extends SmartyError { - constructor() { - super("Request Entity Too Large: The request body has exceeded the maximum size."); + constructor(message?: string) { + super(message ?? "Request Entity Too Large: The request body has exceeded the maximum size."); + this.name = "RequestEntityTooLargeError"; } } export class BadRequestError extends SmartyError { - constructor() { + constructor(message?: string) { super( - "Bad Request (Malformed Payload): A GET request lacked a street field or the request body of a POST request contained malformed JSON.", + message ?? + "Bad Request (Malformed Payload): A GET request lacked a required field or the request body of a POST request contained malformed JSON.", ); + this.name = "BadRequestError"; } } export class UnprocessableEntityError extends SmartyError { - constructor(message: string) { - super(message); + constructor(message?: string) { + super(message ?? "GET request lacked required fields."); + this.name = "UnprocessableEntityError"; } } export class TooManyRequestsError extends SmartyError { - constructor() { - super( - "When using the public 'embedded key' authentication, we restrict the number of requests coming from a given source over too short of a time.", - ); + constructor(message?: string) { + super(message ?? "Too Many Requests: The rate limit for your account has been exceeded."); + this.name = "TooManyRequestsError"; } } export class InternalServerError extends SmartyError { - constructor() { - super("Internal Server Error."); + constructor(message?: string) { + super(message ?? "Internal Server Error."); + this.name = "InternalServerError"; + } +} + +export class BadGatewayError extends SmartyError { + constructor(message?: string) { + super(message ?? "Bad Gateway error."); + this.name = "BadGatewayError"; } } export class ServiceUnavailableError extends SmartyError { - constructor() { - super("Service Unavailable. Try again later."); + constructor(message?: string) { + super(message ?? "Service Unavailable. Try again later."); + this.name = "ServiceUnavailableError"; } } export class GatewayTimeoutError extends SmartyError { - constructor() { + constructor(message?: string) { super( - "The upstream data provider did not respond in a timely fashion and the request failed. A serious, yet rare occurrence indeed.", + message ?? + "The upstream data provider did not respond in a timely fashion and the request failed. A serious, yet rare occurrence indeed.", ); + this.name = "GatewayTimeoutError"; } } @@ -98,8 +141,9 @@ export class NotModifiedError extends SmartyError { constructor(message?: string, responseEtag?: string) { super( message ?? - "Not Modified: the requested record has not changed since the previous request with the Etag value.", + "Not Modified: The requested record has not been modified since the previous request with the Etag value.", ); + this.name = "NotModifiedError"; this.responseEtag = responseEtag; } } @@ -111,11 +155,14 @@ export default { UndefinedLookupError, BadCredentialsError, PaymentRequiredError, + ForbiddenError, + RequestTimeoutError, RequestEntityTooLargeError, BadRequestError, UnprocessableEntityError, TooManyRequestsError, InternalServerError, + BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, NotModifiedError, diff --git a/src/StatusCodeSender.ts b/src/StatusCodeSender.ts index cf7e569..95f447b 100644 --- a/src/StatusCodeSender.ts +++ b/src/StatusCodeSender.ts @@ -1,8 +1,17 @@ import { - InternalServerError, - ServiceUnavailableError, + BadCredentialsError, + BadGatewayError, + BadRequestError, + ForbiddenError, GatewayTimeoutError, + InternalServerError, NotModifiedError, + PaymentRequiredError, + RequestEntityTooLargeError, + RequestTimeoutError, + ServiceUnavailableError, + TooManyRequestsError, + UnprocessableEntityError, DefaultError, } from "./Errors.js"; import { Request, Response, Sender } from "./types.js"; @@ -15,6 +24,27 @@ function extractEtag(headers: Record | undefined): string | unde return undefined; } +// Pulls the message(s) out of the API's JSON error body ({"errors":[{"message":"..."}]}), +// returning undefined when the payload is missing, unparseable, or carries no messages, +// so callers fall back to the standard message for the status code. +function messageFrom(payload: Response["payload"]): string | undefined { + let parsed = payload; + if (typeof parsed === "string") { + try { + parsed = JSON.parse(parsed); + } catch { + return undefined; + } + } + const errors = (parsed as { errors?: { message?: string }[] } | null)?.errors; + if (!Array.isArray(errors)) return undefined; + const message = errors + .map((error) => error?.message ?? "") + .filter((text) => text.trim() !== "") + .join(" "); + return message === "" ? undefined : message; +} + export default class StatusCodeSender { private sender: Sender; @@ -34,6 +64,7 @@ export default class StatusCodeSender { resolve(response); }, (error: Response) => { + const message = messageFrom(error.payload); switch (error.statusCode) { case 0: error.error = error.error ?? new DefaultError("Network error: unable to connect."); @@ -43,24 +74,60 @@ export default class StatusCodeSender { error.error = new NotModifiedError(undefined, extractEtag(error.headers)); break; + case 400: + error.error = new BadRequestError(message); + break; + + case 401: + error.error = new BadCredentialsError(message); + break; + + case 402: + error.error = new PaymentRequiredError(message); + break; + + case 403: + error.error = new ForbiddenError(message); + break; + + case 408: + error.error = new RequestTimeoutError(message); + break; + + case 413: + error.error = new RequestEntityTooLargeError(message); + break; + + case 422: + error.error = new UnprocessableEntityError(message); + break; + + case 429: + error.error = new TooManyRequestsError(message); + break; + case 500: - error.error = new InternalServerError(); + error.error = new InternalServerError(message); + break; + + case 502: + error.error = new BadGatewayError(message); break; case 503: - error.error = new ServiceUnavailableError(); + error.error = new ServiceUnavailableError(message); break; case 504: - error.error = new GatewayTimeoutError(); + error.error = new GatewayTimeoutError(message); break; default: { - const payload = error.payload as { - errors?: { message?: string }[]; - } | null; - const message = payload?.errors?.[0]?.message; - error.error = new DefaultError(message ?? error.error?.message); + error.error = new DefaultError( + message ?? + error.error?.message ?? + `The server returned an unexpected HTTP status code: ${error.statusCode}`, + ); } } reject(error); diff --git a/tests/test_Errors.ts b/tests/test_Errors.ts new file mode 100644 index 0000000..dfdf666 --- /dev/null +++ b/tests/test_Errors.ts @@ -0,0 +1,48 @@ +import { expect } from "chai"; +import errors from "../src/Errors.js"; + +describe("Smarty error classes", function () { + const expectedNames: Record = { + SmartyError: "SmartyError", + DefaultError: "DefaultError", + BatchFullError: "BatchFullError", + BatchEmptyError: "BatchEmptyError", + UndefinedLookupError: "UndefinedLookupError", + BadCredentialsError: "BadCredentialsError", + PaymentRequiredError: "PaymentRequiredError", + ForbiddenError: "ForbiddenError", + RequestTimeoutError: "RequestTimeoutError", + RequestEntityTooLargeError: "RequestEntityTooLargeError", + BadRequestError: "BadRequestError", + UnprocessableEntityError: "UnprocessableEntityError", + TooManyRequestsError: "TooManyRequestsError", + InternalServerError: "InternalServerError", + BadGatewayError: "BadGatewayError", + ServiceUnavailableError: "ServiceUnavailableError", + GatewayTimeoutError: "GatewayTimeoutError", + NotModifiedError: "NotModifiedError", + }; + + it("sets an explicit name on every error class so it survives minification.", function () { + for (const [exportName, expectedName] of Object.entries(expectedNames)) { + const ErrorClass = (errors as any)[exportName]; + const instance = new ErrorClass(); + expect(instance.name, exportName).to.equal(expectedName); + } + }); + + it("keeps the name when a custom message is supplied.", function () { + const error = new errors.BadCredentialsError("message from the API"); + expect(error.name).to.equal("BadCredentialsError"); + expect(error.message).to.equal("message from the API"); + }); + + it("extends SmartyError and Error for every class.", function () { + for (const exportName of Object.keys(expectedNames)) { + const ErrorClass = (errors as any)[exportName]; + const instance = new ErrorClass(); + expect(instance, exportName).to.be.an.instanceOf(errors.SmartyError); + expect(instance, exportName).to.be.an.instanceOf(Error); + } + }); +}); diff --git a/tests/test_StatusCodeSender.ts b/tests/test_StatusCodeSender.ts index 1b0d57c..eb8aa0a 100644 --- a/tests/test_StatusCodeSender.ts +++ b/tests/test_StatusCodeSender.ts @@ -26,23 +26,136 @@ describe("A status code sender", function () { const payload = { errors: [{ message: "custom message" }], }; - return expectedErrorWithPayloadMessage(400, payload); + return expectedErrorWithPayloadMessage(400, payload, errors.BadRequestError, "custom message"); }); - it("returns an error message if payload is undefined", function () { - return expectedDefaultError(); + it("falls back to the standard message if payload is undefined", function () { + return expectedErrorWithFallbackMessage( + 400, + errors.BadRequestError, + "Bad Request (Malformed Payload): A GET request lacked a required field or the request body of a POST request contained malformed JSON.", + ); + }); + + it("gives a Bad Credentials error on a 401.", function () { + return expectedErrorWithFallbackMessage( + 401, + errors.BadCredentialsError, + "Unauthorized: The credentials were provided incorrectly or did not match any existing, active credentials.", + ); + }); + + it("gives a Payment Required error on a 402.", function () { + return expectedErrorWithFallbackMessage( + 402, + errors.PaymentRequiredError, + "Payment Required: There is no active subscription for the account associated with the credentials submitted with the request.", + ); + }); + + it("gives a Forbidden error on a 403.", function () { + return expectedErrorWithFallbackMessage( + 403, + errors.ForbiddenError, + "Forbidden: The request contained valid data and was understood by the server, but the server is refusing action.", + ); + }); + + it("gives a Request Timeout error on a 408.", function () { + return expectedErrorWithFallbackMessage( + 408, + errors.RequestTimeoutError, + "Request timeout error.", + ); + }); + + it("gives a Request Entity Too Large error on a 413.", function () { + return expectedErrorWithFallbackMessage( + 413, + errors.RequestEntityTooLargeError, + "Request Entity Too Large: The request body has exceeded the maximum size.", + ); + }); + + it("gives an Unprocessable Entity error on a 422.", function () { + return expectedErrorWithFallbackMessage( + 422, + errors.UnprocessableEntityError, + "GET request lacked required fields.", + ); + }); + + it("gives a Too Many Requests error on a 429.", function () { + return expectedErrorWithFallbackMessage( + 429, + errors.TooManyRequestsError, + "Too Many Requests: The rate limit for your account has been exceeded.", + ); }); it("gives an Internal Server Error on a 500.", function () { - return expectedErrorForStatusCode(errors.InternalServerError, 500); + return expectedErrorWithFallbackMessage( + 500, + errors.InternalServerError, + "Internal Server Error.", + ); + }); + + it("gives a Bad Gateway error on a 502.", function () { + return expectedErrorWithFallbackMessage(502, errors.BadGatewayError, "Bad Gateway error."); }); it("gives an Service Unavailable error on a 503.", function () { - return expectedErrorForStatusCode(errors.ServiceUnavailableError, 503); + return expectedErrorWithFallbackMessage( + 503, + errors.ServiceUnavailableError, + "Service Unavailable. Try again later.", + ); }); it("gives an Gateway Timeout error on a 504.", function () { - return expectedErrorForStatusCode(errors.GatewayTimeoutError, 504); + return expectedErrorWithFallbackMessage( + 504, + errors.GatewayTimeoutError, + "The upstream data provider did not respond in a timely fashion and the request failed. A serious, yet rare occurrence indeed.", + ); + }); + + it("uses the API message for a 500 when present.", function () { + const payload = { errors: [{ message: "API broke" }] }; + return expectedErrorWithPayloadMessage(500, payload, errors.InternalServerError, "API broke"); + }); + + it("joins multiple API messages.", function () { + const payload = { errors: [{ message: "First problem." }, { message: "Second problem." }] }; + return expectedErrorWithPayloadMessage( + 422, + payload, + errors.UnprocessableEntityError, + "First problem. Second problem.", + ); + }); + + it("parses the API message from a JSON string payload.", function () { + return expectedErrorWithPayloadMessage( + 401, + JSON.stringify({ errors: [{ message: "bad credentials from api" }] }), + errors.BadCredentialsError, + "bad credentials from api", + ); + }); + + it("gives a Default error with the standard message for an unexpected status code.", function () { + return expectedErrorWithFallbackMessage( + 418, + errors.DefaultError, + "The server returned an unexpected HTTP status code: 418", + ); + }); + + it("uses the API message for an unexpected status code when present.", function () { + const payload = { errors: [{ message: "API teapot message" }] }; + return expectedErrorWithPayloadMessage(418, payload, errors.DefaultError, "API teapot message"); }); it("rejects with NotModifiedError on a 304 and captures lowercase etag header", function () { @@ -112,43 +225,43 @@ describe("A status code sender", function () { }); }); -const expectedErrorWithPayloadMessage = (errorCode: any, payload: any) => { +const expectedErrorWithPayloadMessage = ( + errorCode: any, + payload: any, + expectedError: any, + expectedMessage: string, +) => { let mockSender = generateMockSender(errorCode, payload); let statusCodeSender = new StatusCodeSender(mockSender as any); let request = new Request(); return statusCodeSender.send(request).then( - () => {}, - (error) => { - expect(error.error).to.be.an.instanceOf(errors.DefaultError); - expect(error.error.message).to.be.equal(payload.errors[0].message); + () => { + throw new Error("expected rejection"); }, - ); -}; - -const expectedDefaultError = () => { - let mockSender = generateMockSender(400); - let statusCodeSender = new StatusCodeSender(mockSender as any); - let request = new Request(); - - return statusCodeSender.send(request).then( - () => {}, (error) => { - expect(error.error).to.be.an.instanceOf(errors.DefaultError); - expect(error.error.message).to.be.equal("unexpected error"); + expect(error.error).to.be.an.instanceOf(expectedError); + expect(error.error.message).to.be.equal(expectedMessage); }, ); }; -function expectedErrorForStatusCode(expectedError: any, errorCode: any) { +function expectedErrorWithFallbackMessage( + errorCode: any, + expectedError: any, + expectedMessage: string, +) { let mockSender = generateMockSender(errorCode); let statusCodeSender = new StatusCodeSender(mockSender as any); let request = new Request(); return statusCodeSender.send(request).then( - () => {}, + () => { + throw new Error("expected rejection"); + }, (error) => { expect(error.error).to.be.an.instanceOf(expectedError); + expect(error.error.message).to.be.equal(expectedMessage); }, ); }