first commit
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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})
|
||||
}
|
||||
Reference in New Issue
Block a user