import { handleHttpError } from './utils/errorUtils'; /** * Custom error types for better error handling */ export class OxyAuthenticationError extends Error { public readonly code: string; public readonly status: number; constructor(message: string, code = 'AUTH_ERROR', status = 401) { super(message); this.name = 'OxyAuthenticationError'; this.code = code; this.status = status; } } /** * Thrown when an asset's authorized download URL cannot be resolved. * * `getFileDownloadUrlAsync` asks the API for a URL that is valid for the * CALLER and the asset's actual visibility. When that resolution fails there is * no honest fallback: the public CDN origin only serves `public` assets, so * handing back `https://cloud.oxy.so/` for an unresolved asset produces a * hard 404 at render time and hides the real failure from the caller. This * error surfaces the failure instead. * * The message and fields deliberately carry only the asset id, the requested * variant and the HTTP status — never the resolved URL, which embeds a scoped * media token. * * ## `status` lets a caller decide whether a CDN fallback is safe * * Core itself never falls back to the public CDN builder, because it has no * knowledge of the asset's visibility and that URL is a guaranteed 404 for a * private asset. A CALLER that knows an asset is public MAY choose to fall back * to `getFileDownloadUrl(id, variant)` — but only for a TRANSIENT failure, not * a definitive denial: * - `status` 401/403/404 → definitive: the asset is private/denied/missing. * Never CDN-fall-back — it will 404. * - `status` undefined (network error) or 5xx → transient: resolution itself * failed. If the caller independently knows the asset is public, a CDN * fallback is defensible best-effort. */ export class AssetUrlResolutionError extends Error { public readonly code = 'ASSET_URL_UNRESOLVED'; public readonly fileId: string; public readonly variant?: string; public readonly status?: number; /** * The underlying transport/API failure, when there was one. Declared on the * class rather than relying on `Error.cause` because this package targets * ES2020, where `cause` is not part of the `Error` type. */ public readonly cause?: unknown; constructor( fileId: string, variant: string | undefined, status: number | undefined, cause?: unknown, ) { const variantSuffix = variant ? ` (variant "${variant}")` : ''; const statusSuffix = typeof status === 'number' ? ` — status ${status}` : ''; super(`Could not resolve a download URL for asset "${fileId}"${variantSuffix}${statusSuffix}`); this.name = 'AssetUrlResolutionError'; this.fileId = fileId; this.variant = variant; this.status = status; this.cause = cause; } } /** * Thrown when one or more chunks of `getServiceAssetMetadataByIds` could not be * resolved. * * Exists because for this endpoint a FAILED request and an ABSENT asset produce * the same observable result. The server legitimately omits unknown/deleted ids, * so callers are documented to map the response by `id` and treat a missing * entry as "no such asset" — which means a chunk that 429s, times out or 5xxs * reads as authoritative absence unless it is raised. * * That was not hypothetical: a metadata backfill counted every throttled asset * as needing no update and exited 0, and the MTN signed-record builder embedded * media with no content hash into records that are immutable once signed. Both * paths reported success and wrote nothing. * * `unresolvedIds` carries every id in a failed chunk — not the subset the server * would have omitted anyway, which is unknowable when the request never landed. * A caller that wants best-effort passes `{ partial: true }` and never sees this. */ export class ServiceAssetMetadataError extends Error { public readonly code = 'SERVICE_ASSET_METADATA_UNRESOLVED'; /** Every id belonging to a chunk whose request failed. */ public readonly unresolvedIds: string[]; /** HTTP statuses observed across the failed chunks (deduped, ascending). */ public readonly statuses: number[]; /** * The first underlying transport/API failure. Declared on the class rather * than relying on `Error.cause` because this package targets ES2020, where * `cause` is not part of the `Error` type. */ public readonly cause?: unknown; constructor(unresolvedIds: string[], statuses: number[], cause?: unknown) { const uniqueStatuses = Array.from(new Set(statuses)).sort((a, b) => a - b); const statusSuffix = uniqueStatuses.length > 0 ? ` — status ${uniqueStatuses.join(', ')}` : ''; super( `Could not resolve asset metadata for ${unresolvedIds.length} id(s)${statusSuffix}. Treat this as unknown, not as absent; pass { partial: true } for best-effort.`, ); this.name = 'ServiceAssetMetadataError'; this.unresolvedIds = unresolvedIds; this.statuses = uniqueStatuses; this.cause = cause; } } /** * A chunk of `POST /assets/service/linked-url` failed, so the SDK cannot say * whether those ids have URLs. * * Its own class rather than a reuse of {@link ServiceAssetMetadataError}, and * deliberately WITHOUT that one's `{ partial: true }` escape hatch, because the * two mistakes cost different things. Reading a failed metadata lookup as * "absent" embedded a media item with no hash. Reading a failed DOWNLOAD mint as * "absent" tells a buyer who paid that the file they own is not there — and * "not there" is already this route's answer for "not yours", so a best-effort * mode would make a throttled request indistinguishable from a refusal. There is * no download path that legitimately wants that, so there is no option to ask * for it. */ export class ServiceLinkedDownloadUrlError extends Error { public readonly code = 'SERVICE_LINKED_DOWNLOAD_URL_UNRESOLVED'; /** Every id belonging to a chunk whose request failed. */ public readonly unresolvedIds: string[]; /** HTTP statuses observed across the failed chunks (deduped, ascending). */ public readonly statuses: number[]; /** The first underlying transport/API failure — see the sibling class. */ public readonly cause?: unknown; constructor(unresolvedIds: string[], statuses: number[], cause?: unknown) { const uniqueStatuses = Array.from(new Set(statuses)).sort((a, b) => a - b); const statusSuffix = uniqueStatuses.length > 0 ? ` — status ${uniqueStatuses.join(', ')}` : ''; super( `Could not mint download URLs for ${unresolvedIds.length} id(s)${statusSuffix}. Treat this as unknown, never as "the caller holds no right".`, ); this.name = 'ServiceLinkedDownloadUrlError'; this.unresolvedIds = unresolvedIds; this.statuses = uniqueStatuses; this.cause = cause; } } export class OxyAuthenticationTimeoutError extends OxyAuthenticationError { constructor(operationName: string, timeoutMs: number) { super( `Authentication timeout (${timeoutMs}ms): ${operationName} requires user authentication. Please ensure the user is logged in before calling this method.`, 'AUTH_TIMEOUT', 408, ); this.name = 'OxyAuthenticationTimeoutError'; } } /** * The one error every `OxyServices` call rejects with. * * `status` is the HTTP status (0 when no response arrived: a network failure, * a timeout or a cancellation), `code` the API's machine code when it sent one * (else a transport code such as `TIMEOUT`, `NETWORK_ERROR`, `CANCELLED`), and * `details` the API's structured detail. The original error is `cause`. */ export class OxyApiError extends Error { public readonly status: number; public readonly code: string; public readonly details?: Record; /** The raw response, when one arrived. */ public readonly response?: { status: number; statusText: string; data?: unknown }; /** True when the caller cancelled the request (never a failure). */ public readonly cancelled: boolean; /** True when the request timed out. */ public readonly timeout: boolean; constructor( message: string, init: { status: number; code: string; details?: Record; response?: { status: number; statusText: string; data?: unknown }; cancelled?: boolean; timeout?: boolean; cause?: unknown; }, ) { super(message); this.name = 'OxyApiError'; this.status = init.status; this.code = init.code; this.details = init.details; this.response = init.response; this.cancelled = init.cancelled ?? false; this.timeout = init.timeout ?? false; if (init.cause !== undefined) { (this as Error & { cause?: unknown }).cause = init.cause; } } } /** * Normalise anything a request rejected with into an `OxyApiError`. An error * that already is one (or a typed SDK error such as `OxyAuthenticationError`) * passes through unchanged. */ export function toOxyApiError(error: unknown): Error { if (error instanceof OxyApiError || error instanceof OxyAuthenticationError) { return error; } const api = handleHttpError(error); const source = (error && typeof error === 'object' ? error : {}) as { response?: { status: number; statusText: string; data?: unknown }; cancelled?: boolean; timeout?: boolean; }; return new OxyApiError(api.message?.trim() || 'An unexpected error occurred', { status: api.status, code: api.code, details: api.details, response: source.response, cancelled: source.cancelled === true, timeout: source.timeout === true, cause: error, }); }