@lifi/composer-sdk
Version:
Public Composer SDK for building and submitting flows
215 lines (203 loc) • 6.66 kB
text/typescript
import type {
ComposeErrorKind,
FailedPreparedOp,
GenericComposeErrorKind,
SimulationRevert,
} from '@lifi/compose-spec';
import {
parseServerErrorBody,
type ServerErrorBody,
} from './responseSchemas.js';
/**
* Machine-readable error codes returned by the SDK.
*
* - `NETWORK_ERROR` — The HTTP request failed (DNS, timeout, connection refused).
* - `VALIDATION_ERROR` — The server rejected the request (HTTP 400/422).
* - `UNAUTHENTICATED` — The request lacks valid authentication credentials (HTTP 401).
* - `FORBIDDEN` — The server understood the request but refuses to authorise it (HTTP 403).
* - `SERVER_ERROR` — The server returned a 5xx status.
* - `RATE_LIMITED` — The server returned HTTP 429.
* - `NOT_FOUND` — The requested resource does not exist (HTTP 404).
* - `UNKNOWN_ERROR` — An unexpected error that doesn't fit other categories.
*/
export type ComposeErrorCode =
| 'NETWORK_ERROR'
| 'VALIDATION_ERROR'
| 'UNAUTHENTICATED'
| 'FORBIDDEN'
| 'SERVER_ERROR'
| 'RATE_LIMITED'
| 'NOT_FOUND'
| 'UNKNOWN_ERROR';
/**
* Error class for all failures originating from the Compose SDK or API.
*
* Includes structured metadata beyond the error message to support
* programmatic error handling.
*
* @example
* ```ts
* try {
* await builder.compile(run);
* } catch (e) {
* if (isComposeError(e) && e.code === 'VALIDATION_ERROR') {
* console.error('Invalid request:', e.message, e.path);
* }
* }
* ```
*/
export class ComposeError extends Error {
override readonly name = 'ComposeError';
/** Machine-readable error category. */
readonly code: ComposeErrorCode;
/** HTTP status code, when the error originated from an HTTP response. */
readonly status?: number;
/** The request URL that produced the error. */
readonly url?: string;
/** Server-provided error kind for finer-grained classification. */
readonly kind?: ComposeErrorKind;
/** JSON-pointer path to the field that caused a validation error. */
readonly path?: string;
/**
* Simulation revert diagnostics attached to `simulation_revert` errors.
* Contains the raw error bytes and decoded error candidates when the
* backend can parse the revert reason.
*/
readonly details?: SimulationRevert;
/**
* The prepared ops that failed, attached to `preparation_error` errors.
* Each entry carries the `callId` of the failing node so callers can drop
* the unroutable legs and resubmit a smaller flow.
*/
readonly failedOps?: readonly FailedPreparedOp[];
/**
* The `callId`s of the prepared ops that succeeded, attached to
* `preparation_error` errors alongside {@link ComposeError.failedOps}.
*/
readonly succeededOps?: readonly string[];
constructor(
code: ComposeErrorCode,
message: string,
options?: {
status?: number;
url?: string;
cause?: unknown;
kind?: ComposeErrorKind;
path?: string;
details?: SimulationRevert;
failedOps?: readonly FailedPreparedOp[];
succeededOps?: readonly string[];
},
) {
super(message, { cause: options?.cause });
this.code = code;
this.status = options?.status;
this.url = options?.url;
this.kind = options?.kind;
this.path = options?.path;
this.details = options?.details;
this.failedOps = options?.failedOps;
this.succeededOps = options?.succeededOps;
}
}
/**
* The per-op preparation diagnostics carried by `preparation_error` responses,
* with both arrays known to be present. Produced by narrowing with
* {@link isComposePreparationError}.
*/
export interface ComposePreparationOps {
readonly failedOps: readonly FailedPreparedOp[];
readonly succeededOps: readonly string[];
}
/**
* Type guard that narrows an unknown error to {@link ComposeError}.
* @param e - The value to check.
* @returns `true` if `e` is an instance of `ComposeError`.
*/
export const isComposeError = (e: unknown): e is ComposeError =>
e instanceof ComposeError ||
(e instanceof Error && e.name === 'ComposeError' && 'code' in e);
/**
* Type guard for `preparation_error` failures (HTTP 422): a mixed basket where
* some prepared ops failed and others succeeded. Narrows both
* {@link ComposeError.failedOps} and {@link ComposeError.succeededOps} to
* present, so callers can read them without a cast or a truthiness check.
*
* @param e - The value to check.
* @returns `true` if `e` is a `ComposeError` carrying per-op preparation diagnostics.
*
* @example
* ```ts
* try {
* await builder.compile(run);
* } catch (e) {
* if (isComposePreparationError(e)) {
* console.error('Failed ops:', e.failedOps);
* console.error('Succeeded ops:', e.succeededOps);
* }
* }
* ```
*/
export const isComposePreparationError = (
e: unknown,
): e is ComposeError & ComposePreparationOps =>
isComposeError(e) &&
e.kind === 'preparation_error' &&
Array.isArray(e.failedOps) &&
Array.isArray(e.succeededOps);
const STATUS_TO_CODE: ReadonlyMap<number, ComposeErrorCode> = new Map<
number,
ComposeErrorCode
>([
[400, 'VALIDATION_ERROR'],
[401, 'UNAUTHENTICATED'],
[403, 'FORBIDDEN'],
[404, 'NOT_FOUND'],
[422, 'VALIDATION_ERROR'],
[429, 'RATE_LIMITED'],
]);
const tryParseErrorBody = (body: string): ServerErrorBody | null => {
let json: unknown;
try {
json = JSON.parse(body);
} catch {
return null;
}
return parseServerErrorBody(json);
};
/**
* Constructs a {@link ComposeError} from an HTTP error response, extracting
* structured error details from the response body when available.
*
* @param status - The HTTP status code.
* @param body - The raw response body text.
* @param url - The request URL that produced the error.
* @returns A `ComposeError` with the appropriate error code and metadata.
*/
export const errorFromHttpResponse = (
status: number,
body: string,
url: string,
): ComposeError => {
const parsed = tryParseErrorBody(body);
const serverError = parsed?.error;
return new ComposeError(
STATUS_TO_CODE.get(status) ??
(status >= 500 ? 'SERVER_ERROR' : 'UNKNOWN_ERROR'),
(serverError?.message ?? body) || `HTTP ${status}`,
{
status,
url,
kind: serverError?.kind as ComposeErrorKind | undefined,
path: serverError?.path,
details: serverError?.details,
// The wire schema types `kind` as a bare string; the server only ever
// reports generic kinds for individual ops.
failedOps: serverError?.failedOps?.map((op) => ({
...op,
kind: op.kind as GenericComposeErrorKind,
})),
succeededOps: serverError?.succeededOps,
},
);
};