Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 68 additions & 21 deletions src/Errors.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}

Expand All @@ -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;
}
}
Expand All @@ -111,11 +155,14 @@ export default {
UndefinedLookupError,
BadCredentialsError,
PaymentRequiredError,
ForbiddenError,
RequestTimeoutError,
RequestEntityTooLargeError,
BadRequestError,
UnprocessableEntityError,
TooManyRequestsError,
InternalServerError,
BadGatewayError,
ServiceUnavailableError,
GatewayTimeoutError,
NotModifiedError,
Expand Down
87 changes: 77 additions & 10 deletions src/StatusCodeSender.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +24,27 @@ function extractEtag(headers: Record<string, string> | 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;

Expand All @@ -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.");
Expand All @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions tests/test_Errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect } from "chai";
import errors from "../src/Errors.js";

describe("Smarty error classes", function () {
const expectedNames: Record<string, string> = {
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);
}
});
});
Loading