Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): improved error objects #1217

Merged
merged 5 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
282 changes: 282 additions & 0 deletions api/_errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import type { VercelResponse } from "@vercel/node";
import { AxiosError } from "axios";
import { StructError } from "superstruct";
import { relayFeeCalculator, typeguards } from "@across-protocol/sdk";
import { ethers } from "ethers";

type AcrossApiErrorCodeKey = keyof typeof AcrossErrorCode;

export const HttpErrorToStatusCode = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
REQUEST_TIMEOUT: 408,
CONFLICT: 409,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
} as const;

export const AcrossErrorCode = {
// Status: 40X
INVALID_PARAM: "INVALID_PARAM",
MISSING_PARAM: "MISSING_PARAM",
SIMULATION_ERROR: "SIMULATION_ERROR",
AMOUNT_TOO_LOW: "AMOUNT_TOO_LOW",
AMOUNT_TOO_HIGH: "AMOUNT_TOO_HIGH",
ROUTE_NOT_ENABLED: "ROUTE_NOT_ENABLED",

// Status: 50X
UPSTREAM_RPC_ERROR: "UPSTREAM_RPC_ERROR",
UPSTREAM_HTTP_ERROR: "UPSTREAM_HTTP_ERROR",
} as const;

export class AcrossApiError extends Error {
code?: AcrossApiErrorCodeKey;
status: number;
message: string;
param?: string;

constructor(
args: {
code?: AcrossApiErrorCodeKey;
status: number;
message: string;
param?: string;
},
opts?: ErrorOptions
) {
super(args.message, opts);
this.code = args.code;
this.status = args.status;
this.message = args.message;
this.param = args.param;
}

toJSON() {
return {
type: "AcrossApiError",
code: this.code,
status: this.status,
message: this.message,
param: this.param,
};
}
}

export class InputError extends AcrossApiError {
constructor(
args: {
message: string;
code: AcrossApiErrorCodeKey;
param?: string;
},
opts?: ErrorOptions
) {
super(
{
...args,
status: HttpErrorToStatusCode.BAD_REQUEST,
},
opts
);
}
}

export class InvalidParamError extends InputError {
constructor(args: { message: string; param?: string }) {
super({
message: args.message,
code: AcrossErrorCode.INVALID_PARAM,
param: args.param,
});
}
}

export class MissingParamError extends InputError {
constructor(args: { message: string; param?: string }) {
super({
message: args.message,
code: AcrossErrorCode.MISSING_PARAM,
param: args.param,
});
}
}

export class SimulationError extends InputError {
constructor(args: { message: string }, opts?: ErrorOptions) {
super(
{
message: args.message,
code: AcrossErrorCode.SIMULATION_ERROR,
},
opts
);
}
}

export class RouteNotEnabledError extends InputError {
constructor(args: { message: string }, opts?: ErrorOptions) {
super(
{
message: args.message,
code: AcrossErrorCode.ROUTE_NOT_ENABLED,
},
opts
);
}
}

export class AmountTooLowError extends InputError {
constructor(args: { message: string }, opts?: ErrorOptions) {
super(
{
message: args.message,
code: AcrossErrorCode.AMOUNT_TOO_LOW,
},
opts
);
}
}

export class AmountTooHighError extends InputError {
constructor(args: { message: string }, opts?: ErrorOptions) {
super(
{
message: args.message,
code: AcrossErrorCode.AMOUNT_TOO_HIGH,
},
opts
);
}
}

/**
* Handles the recurring case of error handling
* @param endpoint A string numeric to indicate to the logging utility where this error occurs
* @param response A VercelResponse object that is used to interract with the returning reponse
* @param logger A logging utility to write to a cloud logging provider
* @param error The error that will be returned to the user
* @returns The `response` input with a status/send sent. Note: using this object again will cause an exception
*/
export function handleErrorCondition(
endpoint: string,
response: VercelResponse,
logger: relayFeeCalculator.Logger,
error: unknown
): VercelResponse {
let acrossApiError: AcrossApiError;

// Handle superstruct validation errors
if (error instanceof StructError) {
const { type, path } = error;
// Sanitize the error message that will be sent to client
const message = `Invalid parameter at path '${path}'. Expected type '${type}'`;
acrossApiError = new InputError({
message,
code: AcrossErrorCode.INVALID_PARAM,
param: path.join("."),
});
}
// Handle axios errors
else if (error instanceof AxiosError) {
const { response } = error;

// If upstream error is an AcrossApiError, we just return it
if (response?.data?.type === "AcrossApiError") {
acrossApiError = new AcrossApiError(
{
message: response.data.message,
status: response.data.status,
code: response.data.code,
param: response.data.param,
},
{ cause: error }
);
} else {
const message = `Upstream http request to ${error.request?.url} failed with ${error.status} ${error.message}`;
acrossApiError = new AcrossApiError(
{
message,
status: HttpErrorToStatusCode.BAD_GATEWAY,
code: AcrossErrorCode.UPSTREAM_HTTP_ERROR,
},
{ cause: error }
);
}
}
// Handle ethers errors
else if (typeguards.isEthersError(error)) {
acrossApiError = resolveEthersError(error);
}
// Handle other errors
else if (error instanceof Error) {
acrossApiError = new AcrossApiError(
{
message: error.message,
status: HttpErrorToStatusCode.INTERNAL_SERVER_ERROR,
},
{ cause: error }
);
} else {
acrossApiError = error as AcrossApiError;
}

const logLevel = acrossApiError.status >= 500 ? "error" : "warn";
logger[logLevel]({
at: endpoint,
message: `${acrossApiError.code}: ${acrossApiError.message}`,
});

return response.status(acrossApiError.status).json(acrossApiError);
}

export function resolveEthersError(err: unknown) {
if (!typeguards.isEthersError(err)) {
return new AcrossApiError(
{
message: err instanceof Error ? err.message : "Unknown error",
status: HttpErrorToStatusCode.INTERNAL_SERVER_ERROR,
code: AcrossErrorCode.UPSTREAM_RPC_ERROR,
},
{ cause: err }
);
}

const { reason, code } = err;
const method = "method" in err ? (err.method as string) : undefined;

// Simulation errors
if (
method === "estimateGas" &&
(code === ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT ||
code === ethers.utils.Logger.errors.CALL_EXCEPTION)
) {
const rpcErrorMessageMatch = reason
.replace(/\\/g, "")
.match(/"message":"((?:[^"\\]|\\.)*)"/);
const rpcErrorMessage = rpcErrorMessageMatch
? rpcErrorMessageMatch[1]
: reason;

return new SimulationError(
{
message: rpcErrorMessage,
},
{ cause: err }
);
}

return new AcrossApiError(
{
message: `${err.reason}: ${err.code} - ${err.error}`,
status: HttpErrorToStatusCode.INTERNAL_SERVER_ERROR,
code: AcrossErrorCode.UPSTREAM_RPC_ERROR,
},
{ cause: err }
);
}
Loading
Loading