184 lines
6.4 KiB
TypeScript
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})
|
|
}
|