diff --git a/packages/durably/src/durably.ts b/packages/durably/src/durably.ts index 45addc8..5b70382 100644 --- a/packages/durably/src/durably.ts +++ b/packages/durably/src/durably.ts @@ -4,7 +4,13 @@ import { monotonicFactory } from 'ulidx' import type { z } from 'zod' import { createStepContext } from './context' import type { JobDefinition } from './define-job' -import { CancelledError, getErrorMessage, LeaseLostError } from './errors' +import { + CancelledError, + ConflictError, + getErrorMessage, + LeaseLostError, + NotFoundError, +} from './errors' import { type AnyEventInput, type DurablyEvent, @@ -435,7 +441,7 @@ function createDurablyInstance< async function getRunOrThrow(runId: string): Promise> { const run = await storage.getRun(runId) if (!run) { - throw new Error(`Run not found: ${runId}`) + throw new NotFoundError(`Run not found: ${runId}`) } return run as Run } @@ -808,14 +814,14 @@ function createDurablyInstance< async retrigger(runId: string): Promise> { const run = await getRunOrThrow(runId) if (run.status === 'pending') { - throw new Error(`Cannot retrigger pending run: ${runId}`) + throw new ConflictError(`Cannot retrigger pending run: ${runId}`) } if (run.status === 'leased') { - throw new Error(`Cannot retrigger leased run: ${runId}`) + throw new ConflictError(`Cannot retrigger leased run: ${runId}`) } const job = jobRegistry.get(run.jobName) if (!job) { - throw new Error(`Unknown job: ${run.jobName}`) + throw new NotFoundError(`Unknown job: ${run.jobName}`) } // Validate original input against current schema @@ -846,13 +852,13 @@ function createDurablyInstance< async cancel(runId: string): Promise { const run = await getRunOrThrow(runId) if (run.status === 'completed') { - throw new Error(`Cannot cancel completed run: ${runId}`) + throw new ConflictError(`Cannot cancel completed run: ${runId}`) } if (run.status === 'failed') { - throw new Error(`Cannot cancel failed run: ${runId}`) + throw new ConflictError(`Cannot cancel failed run: ${runId}`) } if (run.status === 'cancelled') { - throw new Error(`Cannot cancel already cancelled run: ${runId}`) + throw new ConflictError(`Cannot cancel already cancelled run: ${runId}`) } const wasPending = run.status === 'pending' const cancelled = await storage.cancelRun(runId, new Date().toISOString()) @@ -860,7 +866,7 @@ function createDurablyInstance< if (!cancelled) { // Run transitioned to a terminal state between the check and the update const current = await getRunOrThrow(runId) - throw new Error( + throw new ConflictError( `Cannot cancel run ${runId}: status changed to ${current.status}`, ) } @@ -882,10 +888,10 @@ function createDurablyInstance< async deleteRun(runId: string): Promise { const run = await getRunOrThrow(runId) if (run.status === 'pending') { - throw new Error(`Cannot delete pending run: ${runId}`) + throw new ConflictError(`Cannot delete pending run: ${runId}`) } if (run.status === 'leased') { - throw new Error(`Cannot delete leased run: ${runId}`) + throw new ConflictError(`Cannot delete leased run: ${runId}`) } await storage.deleteRun(runId) diff --git a/packages/durably/src/errors.ts b/packages/durably/src/errors.ts index 8817880..9a712be 100644 --- a/packages/durably/src/errors.ts +++ b/packages/durably/src/errors.ts @@ -20,6 +20,43 @@ export class LeaseLostError extends Error { } } +/** + * Base class for errors that map to specific HTTP status codes. + * Used by the HTTP handler to return appropriate responses. + */ +export class DurablyError extends Error { + readonly statusCode: number + constructor(message: string, statusCode: number) { + super(message) + this.name = 'DurablyError' + this.statusCode = statusCode + } +} + +/** 404 — Resource not found */ +export class NotFoundError extends DurablyError { + constructor(message: string) { + super(message, 404) + this.name = 'NotFoundError' + } +} + +/** 400 — Invalid input or request */ +export class ValidationError extends DurablyError { + constructor(message: string) { + super(message, 400) + this.name = 'ValidationError' + } +} + +/** 409 — Operation conflicts with current state */ +export class ConflictError extends DurablyError { + constructor(message: string) { + super(message, 409) + this.name = 'ConflictError' + } +} + /** * Extract error message from unknown error */ diff --git a/packages/durably/src/http.ts b/packages/durably/src/http.ts index bc3834e..2349c8e 100644 --- a/packages/durably/src/http.ts +++ b/packages/durably/src/http.ts @@ -29,7 +29,7 @@ export function jsonResponse(data: unknown, status = 200): Response { */ export function errorResponse( message: string, - status: 400 | 404 | 500 = 500, + status: 400 | 404 | 409 | 500 = 500, ): Response { return jsonResponse({ error: message }, status) } diff --git a/packages/durably/src/index.ts b/packages/durably/src/index.ts index cce1fe4..a77b56d 100644 --- a/packages/durably/src/index.ts +++ b/packages/durably/src/index.ts @@ -69,7 +69,14 @@ export type { } from './storage' // Errors -export { CancelledError, LeaseLostError } from './errors' +export { + CancelledError, + ConflictError, + DurablyError, + LeaseLostError, + NotFoundError, + ValidationError, +} from './errors' // Server export { createDurablyHandler } from './server' diff --git a/packages/durably/src/job.ts b/packages/durably/src/job.ts index 7c50e9b..437698e 100644 --- a/packages/durably/src/job.ts +++ b/packages/durably/src/job.ts @@ -1,5 +1,6 @@ import { type z, prettifyError } from 'zod' import type { JobDefinition } from './define-job' +import { ValidationError } from './errors' import type { EventEmitter, LogData, ProgressData } from './events' import type { Run, RunFilter, Store } from './storage' @@ -17,7 +18,9 @@ export function validateJobInputOrThrow( const result = schema.safeParse(input) if (!result.success) { const prefix = context ? `${context}: ` : '' - throw new Error(`${prefix}Invalid input: ${prettifyError(result.error)}`) + throw new ValidationError( + `${prefix}Invalid input: ${prettifyError(result.error)}`, + ) } return result.data } diff --git a/packages/durably/src/server.ts b/packages/durably/src/server.ts index 20c7917..89d291c 100644 --- a/packages/durably/src/server.ts +++ b/packages/durably/src/server.ts @@ -1,8 +1,8 @@ import type { Durably } from './durably' +import { DurablyError, getErrorMessage } from './errors' import type { AnyEventInput } from './events' import { errorResponse, - getErrorMessage, getRequiredQueryParam, jsonResponse, successResponse, @@ -267,7 +267,7 @@ export function createDurablyHandler< // --- Shared helpers --- - /** Wrap handler with try/catch that re-throws Response and catches everything else as 500 */ + /** Wrap handler with try/catch that maps DurablyError to proper HTTP status */ async function withErrorHandling( fn: () => Promise, ): Promise { @@ -275,6 +275,12 @@ export function createDurablyHandler< return await fn() } catch (error) { if (error instanceof Response) throw error + if (error instanceof DurablyError) { + return errorResponse( + error.message, + error.statusCode as 400 | 404 | 409 | 500, + ) + } return errorResponse(getErrorMessage(error), 500) } } @@ -323,14 +329,11 @@ export function createDurablyHandler< await auth.onTrigger(ctx as TContext, body) } - const run = await job.trigger( - (body.input ?? {}) as Record, - { - idempotencyKey: body.idempotencyKey, - concurrencyKey: body.concurrencyKey, - labels: body.labels, - }, - ) + const run = await job.trigger(body.input as Record, { + idempotencyKey: body.idempotencyKey, + concurrencyKey: body.concurrencyKey, + labels: body.labels, + }) const response: TriggerResponse = { runId: run.id } return jsonResponse(response) diff --git a/packages/durably/tests/shared/server.shared.ts b/packages/durably/tests/shared/server.shared.ts index 681d607..7fb366b 100644 --- a/packages/durably/tests/shared/server.shared.ts +++ b/packages/durably/tests/shared/server.shared.ts @@ -574,7 +574,7 @@ export function createServerTests(createDialect: () => Dialect) { expect(body.error).toBe('runId query parameter is required') }) - it('returns 500 when retriggering a pending run', async () => { + it('returns 409 when retriggering a pending run', async () => { const d = durably.register({ job: defineJob({ name: 'retrigger-pending-test', @@ -590,7 +590,7 @@ export function createServerTests(createDialect: () => Dialect) { ) const response = await handler.handle(request, '/api/durably') - expect(response.status).toBe(500) + expect(response.status).toBe(409) }) }) @@ -632,7 +632,7 @@ export function createServerTests(createDialect: () => Dialect) { expect(body.error).toBe('runId query parameter is required') }) - it('returns 500 when cancelling completed run', async () => { + it('returns 409 when cancelling completed run', async () => { const d = durably.register({ job: defineJob({ name: 'cancel-completed-test', @@ -657,7 +657,7 @@ export function createServerTests(createDialect: () => Dialect) { ) const response = await handler.handle(request, '/api/durably') - expect(response.status).toBe(500) + expect(response.status).toBe(409) }) })