@durable-streams/client
Version:
TypeScript client for the Durable Streams protocol
210 lines (188 loc) • 5.15 kB
text/typescript
import type { DurableStreamErrorCode } from "./types"
/**
* Error thrown for transport/network errors.
* Following the @electric-sql/client FetchError pattern.
*/
export class FetchError extends Error {
status: number
text?: string
json?: object
headers: Record<string, string>
constructor(
status: number,
text: string | undefined,
json: object | undefined,
headers: Record<string, string>,
public url: string,
message?: string
) {
super(
message ||
`HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`
)
this.name = `FetchError`
this.status = status
this.text = text
this.json = json
this.headers = headers
}
static async fromResponse(
response: Response,
url: string
): Promise<FetchError> {
const status = response.status
const headers = Object.fromEntries([...response.headers.entries()])
let text: string | undefined = undefined
let json: object | undefined = undefined
const contentType = response.headers.get(`content-type`)
if (!response.bodyUsed && response.body !== null) {
if (contentType && contentType.includes(`application/json`)) {
try {
json = (await response.json()) as object
} catch {
// If JSON parsing fails, fall back to text
text = await response.text()
}
} else {
text = await response.text()
}
}
return new FetchError(status, text, json, headers, url)
}
}
/**
* Error thrown when a fetch operation is aborted during backoff.
*/
export class FetchBackoffAbortError extends Error {
constructor() {
super(`Fetch with backoff aborted`)
this.name = `FetchBackoffAbortError`
}
}
/**
* Protocol-level error for Durable Streams operations.
* Provides structured error handling with error codes.
*/
export class DurableStreamError extends Error {
/**
* HTTP status code, if applicable.
*/
status?: number
/**
* Structured error code for programmatic handling.
*/
code: DurableStreamErrorCode
/**
* Additional error details (e.g., raw response body).
*/
details?: unknown
constructor(
message: string,
code: DurableStreamErrorCode,
status?: number,
details?: unknown
) {
super(message)
this.name = `DurableStreamError`
this.code = code
this.status = status
this.details = details
}
/**
* Create a DurableStreamError from an HTTP response.
*/
static async fromResponse(
response: Response,
url: string
): Promise<DurableStreamError> {
const status = response.status
let details: unknown
const contentType = response.headers.get(`content-type`)
if (!response.bodyUsed && response.body !== null) {
if (contentType && contentType.includes(`application/json`)) {
try {
details = await response.json()
} catch {
details = await response.text()
}
} else {
details = await response.text()
}
}
const code = statusToCode(status)
const message = `Durable stream error at ${url}: ${response.statusText || status}`
return new DurableStreamError(message, code, status, details)
}
/**
* Create a DurableStreamError from a FetchError.
*/
static fromFetchError(error: FetchError): DurableStreamError {
const code = statusToCode(error.status)
return new DurableStreamError(
error.message,
code,
error.status,
error.json ?? error.text
)
}
}
/**
* Map HTTP status codes to DurableStreamErrorCode.
*/
function statusToCode(status: number): DurableStreamErrorCode {
switch (status) {
case 400:
return `BAD_REQUEST`
case 401:
return `UNAUTHORIZED`
case 403:
return `FORBIDDEN`
case 404:
return `NOT_FOUND`
case 409:
// Could be CONFLICT_SEQ or CONFLICT_EXISTS depending on context
// Default to CONFLICT_SEQ, caller can override
return `CONFLICT_SEQ`
case 429:
return `RATE_LIMITED`
case 503:
return `BUSY`
default:
return `UNKNOWN`
}
}
/**
* Error thrown when stream URL is missing.
*/
export class MissingStreamUrlError extends Error {
constructor() {
super(`Invalid stream options: missing required url parameter`)
this.name = `MissingStreamUrlError`
}
}
/**
* Error thrown when attempting to append to a closed stream.
*/
export class StreamClosedError extends DurableStreamError {
readonly code = `STREAM_CLOSED` as const
readonly status = 409
readonly streamClosed = true
/**
* The final offset of the stream, if available from the response.
*/
readonly finalOffset?: string
constructor(url?: string, finalOffset?: string) {
super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url)
this.name = `StreamClosedError`
this.finalOffset = finalOffset
}
}
/**
* Error thrown when signal option is invalid.
*/
export class InvalidSignalError extends Error {
constructor() {
super(`Invalid signal option. It must be an instance of AbortSignal.`)
this.name = `InvalidSignalError`
}
}