UNPKG

@lifi/composer-sdk

Version:

Public Composer SDK for building and submitting flows

328 lines (309 loc) 10.4 kB
import type { ComposeErrorKind, ComposeRouteErrorKind, FailedPreparedOp, GenericComposeErrorKind, SimulationRevert, } from '@lifi/compose-spec'; import z from 'zod'; 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'; /** Version details returned when the backend rejects an outdated SDK. */ export interface ComposeSdkOutdated { readonly sdkVersion: string; readonly serverVersion: string; readonly minimumSdkVersion: string; } /** * Version details attached when the SDK is ahead of the backend it reached. * Raised by the backend's version gate, or by the SDK itself when the * `x-lifi-composer-version` response header names an older contract than the * one this SDK was built against. */ export interface ComposeServerOutdated { readonly sdkVersion: string; readonly serverVersion: string; } /** * 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 | ComposeRouteErrorKind; /** 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[]; /** Version details attached only to `sdk_outdated` errors. */ readonly sdkOutdated?: ComposeSdkOutdated; /** Version details attached only to `server_outdated` errors. */ readonly serverOutdated?: ComposeServerOutdated; constructor( code: ComposeErrorCode, message: string, options?: { status?: number; url?: string; cause?: unknown; kind?: ComposeErrorKind | ComposeRouteErrorKind; path?: string; details?: SimulationRevert; failedOps?: readonly FailedPreparedOp[]; succeededOps?: readonly string[]; sdkOutdated?: ComposeSdkOutdated; serverOutdated?: ComposeServerOutdated; }, ) { 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; this.sdkOutdated = options?.sdkOutdated; this.serverOutdated = options?.serverOutdated; } } /** * 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); // `isComposeError` accepts a structurally-similar error by name, so an error // raised by a *second* copy of this package in the dependency tree reaches the // guard below without its `sdkOutdated` field being type-guaranteed. Parse it. const sdkOutdatedSchema = z.object({ sdkVersion: z.string(), serverVersion: z.string(), minimumSdkVersion: z.string(), }); /** * Narrows an outdated-SDK rejection to its required version details. * * @example * ```ts * try { * await builder.compile(run); * } catch (e) { * if (isComposeSdkOutdatedError(e)) { * console.error(`Upgrade to at least ${e.sdkOutdated.minimumSdkVersion}`); * return; * } * throw e; * } * ``` */ export const isComposeSdkOutdatedError = ( e: unknown, ): e is ComposeError & { readonly sdkOutdated: ComposeSdkOutdated } => isComposeError(e) && e.kind === 'sdk_outdated' && sdkOutdatedSchema.safeParse(e.sdkOutdated).success; const serverOutdatedSchema = z.object({ sdkVersion: z.string(), serverVersion: z.string(), }); /** * Narrows a server-behind-SDK rejection to its required version details. * * This is the deploy-window error: the SDK on npm is ahead of the backend that * answered. Pin the SDK to the server's `major.minor`, or wait for the rollout. * * @example * ```ts * try { * await builder.compile(run); * } catch (e) { * if (isComposeServerOutdatedError(e)) { * console.error(`Server serves ${e.serverOutdated.serverVersion}`); * return; * } * throw e; * } * ``` */ export const isComposeServerOutdatedError = ( e: unknown, ): e is ComposeError & { readonly serverOutdated: ComposeServerOutdated } => isComposeError(e) && e.kind === 'server_outdated' && serverOutdatedSchema.safeParse(e.serverOutdated).success; 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); }; /** * Extracts the version triple an `sdk_outdated` or `server_outdated` envelope * carries. The slots reuse the same schemas the type guards narrow with. Zod * strips keys the schema does not declare, so the parsed value is exactly the * triple; a partial payload fails the parse and yields `undefined` rather than * a half-populated object. */ const readVersionSlots = (serverError: ServerErrorBody['error']) => ({ sdkOutdated: serverError?.kind === 'sdk_outdated' ? sdkOutdatedSchema.safeParse(serverError).data : undefined, serverOutdated: serverError?.kind === 'server_outdated' ? serverOutdatedSchema.safeParse(serverError).data : undefined, }); /** * 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; const { sdkOutdated, serverOutdated } = readVersionSlots(serverError); 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 | ComposeRouteErrorKind | 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, sdkOutdated, serverOutdated, }, ); };