Files
floci-ui/packages/api/src/cloud-spi/errors.ts
T
2026-09-22 16:13:21 -04:00

184 lines
6.4 KiB
TypeScript

/**
* Typed errors for the cloud SPI.
*
* Adapters throw these instead of bare `Error`s so `routes/clouds.ts` can map a
* failure to an HTTP status without pattern-matching on message text. The wire
* shape is deliberately unchanged from the hand-rolled mapper it replaces:
* `message` carries a generic label and `detail` carries the raw runtime text.
*/
export type CloudErrorCode =
| 'invalid_request'
| 'access_denied'
| 'resource_not_found'
| 'resource_conflict'
| 'rate_limited'
| 'operation_not_supported'
| 'operation_not_implemented'
| 'runtime_unavailable'
| 'runtime_error'
| 'cosmos_nosql_unavailable'
export type CloudErrorStatus = 400 | 403 | 404 | 409 | 429 | 501 | 502 | 503
export interface CloudErrorBody {
/** Mirrors `message`; kept for clients that read `error`. */
error: string
code: string
message: string
detail?: string
}
export abstract class CloudError extends Error {
abstract readonly status: CloudErrorStatus
abstract readonly code: CloudErrorCode
/**
* Generic, user-facing label. `message` holds the raw runtime text and is
* surfaced as `detail`, so a label change never leaks adapter internals.
*/
protected abstract readonly label: string
constructor(message: string, options?: {cause?: unknown}) {
super(message, options?.cause === undefined ? undefined : {cause: options.cause})
this.name = new.target.name
}
toBody(): CloudErrorBody {
const message = this.label
const detail = this.message
return {
error: message,
code: this.code,
message,
...(detail && detail !== message ? {detail} : {}),
}
}
}
/** 400 — the caller sent something unusable. The raw message is the label. */
export class ValidationError extends CloudError {
readonly status = 400 as const
readonly code = 'invalid_request' as const
protected get label(): string {
return this.message
}
}
export class AccessDeniedError extends CloudError {
readonly status = 403 as const
readonly code = 'access_denied' as const
protected readonly label = 'Access denied by the runtime'
}
export class NotFoundError extends CloudError {
readonly status = 404 as const
readonly code = 'resource_not_found' as const
protected readonly label = 'Resource not found'
}
export class ConflictError extends CloudError {
readonly status = 409 as const
readonly code = 'resource_conflict' as const
protected readonly label = 'Resource already exists or is in use'
}
export class RateLimitedError extends CloudError {
readonly status = 429 as const
readonly code = 'rate_limited' as const
protected readonly label = 'Runtime is throttling requests'
}
/** 501 — this adapter does not implement the operation at all. */
export class NotSupportedError extends CloudError {
readonly status = 501 as const
readonly code = 'operation_not_supported' as const
protected readonly label = 'Operation is not supported by this adapter'
}
/** 501 — the adapter supports it but the local runtime answered NotImplemented. */
export class NotImplementedByRuntimeError extends CloudError {
readonly status = 501 as const
readonly code = 'operation_not_implemented' as const
protected readonly label = 'Operation is not implemented by the selected runtime'
}
export class RuntimeUnavailableError extends CloudError {
readonly status = 503 as const
readonly code = 'runtime_unavailable' as const
protected readonly label = 'Runtime unavailable'
}
export class RuntimeError extends CloudError {
readonly status = 502 as const
readonly code = 'runtime_error' as const
protected readonly label = 'Runtime request failed'
}
/** Transport-level failure codes that mean "nothing is listening over there". */
const UNREACHABLE_CAUSE_CODES = new Set([
'ECONNREFUSED',
'ENOTFOUND',
'EAI_AGAIN',
'ECONNRESET',
'EHOSTUNREACH',
'ETIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_SOCKET',
])
/**
* Map a runtime HTTP status onto the matching `CloudError`. Shared by the Azure
* and GCP REST clients so both report a 404 or a 501 the same way.
*/
export function httpStatusToCloudError(status: number, message: string, options?: {cause?: unknown}): CloudError {
if (status === 400) return new ValidationError(message, options)
if (status === 401 || status === 403) return new AccessDeniedError(message, options)
if (status === 404) return new NotFoundError(message, options)
if (status === 409) return new ConflictError(message, options)
if (status === 429) return new RateLimitedError(message, options)
if (status === 501) return new NotImplementedByRuntimeError(message, options)
if (status === 502 || status === 503 || status === 504) return new RuntimeUnavailableError(message, options)
return new RuntimeError(message, options)
}
/** True when the failure is a transport error rather than an HTTP response. */
export function isUnreachableCause(err: unknown): boolean {
if (!(err instanceof Error)) return false
if (err.name === 'TimeoutError' || err.name === 'AbortError') return true
let cause: unknown = err
for (let depth = 0; depth < 5 && cause instanceof Error; depth += 1) {
const code = (cause as {code?: unknown}).code
if (typeof code === 'string' && UNREACHABLE_CAUSE_CODES.has(code)) return true
cause = (cause as {cause?: unknown}).cause
}
return false
}
/**
* The single place adapter failures become HTTP responses.
*
* `mapVendorError` lets a provider contribute SDK-specific knowledge (see
* `adapter-aws/awsErrors.ts`) without `cloud-spi` importing any vendor package.
*/
export function toHttpError(
err: unknown,
mapVendorError?: (err: unknown) => CloudError | null,
): {status: CloudErrorStatus; body: CloudErrorBody} {
const mapped = resolveCloudError(err, mapVendorError)
return {status: mapped.status, body: mapped.toBody()}
}
function resolveCloudError(err: unknown, mapVendorError?: (err: unknown) => CloudError | null): CloudError {
if (err instanceof CloudError) return err
const vendor = mapVendorError?.(err)
if (vendor) return vendor
const message = err instanceof Error ? err.message : 'Runtime request failed'
if (isUnreachableCause(err)) return new RuntimeUnavailableError(message, {cause: err})
return new RuntimeError(message, {cause: err})
}