Skip to content

Commit 6645766

Browse files
cojiclaude
andauthored
fix: return proper HTTP status codes and stop coercing input (#129)
#125: HTTP API now returns correct status codes: - 400 for validation errors (bad input, malformed JSON) - 404 for not found (run, job) - 409 for conflict (retrigger pending/leased, cancel completed, delete active) - 500 only for unexpected internal errors Added typed error classes: DurablyError, NotFoundError, ValidationError, ConflictError — all exported for user-land error handling. #127: Remove all input coercion in POST /trigger. body.input is passed directly to job.trigger() — Zod handles validation and returns 400 for invalid/missing input. No more silent null→{} or undefined→{} conversion. Closes #125 Closes #127 Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 4b753e3 commit 6645766

7 files changed

Lines changed: 84 additions & 28 deletions

File tree

packages/durably/src/durably.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { monotonicFactory } from 'ulidx'
44
import type { z } from 'zod'
55
import { createStepContext } from './context'
66
import type { JobDefinition } from './define-job'
7-
import { CancelledError, getErrorMessage, LeaseLostError } from './errors'
7+
import {
8+
CancelledError,
9+
ConflictError,
10+
getErrorMessage,
11+
LeaseLostError,
12+
NotFoundError,
13+
} from './errors'
814
import {
915
type AnyEventInput,
1016
type DurablyEvent,
@@ -435,7 +441,7 @@ function createDurablyInstance<
435441
async function getRunOrThrow(runId: string): Promise<Run<TLabels>> {
436442
const run = await storage.getRun(runId)
437443
if (!run) {
438-
throw new Error(`Run not found: ${runId}`)
444+
throw new NotFoundError(`Run not found: ${runId}`)
439445
}
440446
return run as Run<TLabels>
441447
}
@@ -808,14 +814,14 @@ function createDurablyInstance<
808814
async retrigger(runId: string): Promise<Run<TLabels>> {
809815
const run = await getRunOrThrow(runId)
810816
if (run.status === 'pending') {
811-
throw new Error(`Cannot retrigger pending run: ${runId}`)
817+
throw new ConflictError(`Cannot retrigger pending run: ${runId}`)
812818
}
813819
if (run.status === 'leased') {
814-
throw new Error(`Cannot retrigger leased run: ${runId}`)
820+
throw new ConflictError(`Cannot retrigger leased run: ${runId}`)
815821
}
816822
const job = jobRegistry.get(run.jobName)
817823
if (!job) {
818-
throw new Error(`Unknown job: ${run.jobName}`)
824+
throw new NotFoundError(`Unknown job: ${run.jobName}`)
819825
}
820826

821827
// Validate original input against current schema
@@ -846,21 +852,21 @@ function createDurablyInstance<
846852
async cancel(runId: string): Promise<void> {
847853
const run = await getRunOrThrow(runId)
848854
if (run.status === 'completed') {
849-
throw new Error(`Cannot cancel completed run: ${runId}`)
855+
throw new ConflictError(`Cannot cancel completed run: ${runId}`)
850856
}
851857
if (run.status === 'failed') {
852-
throw new Error(`Cannot cancel failed run: ${runId}`)
858+
throw new ConflictError(`Cannot cancel failed run: ${runId}`)
853859
}
854860
if (run.status === 'cancelled') {
855-
throw new Error(`Cannot cancel already cancelled run: ${runId}`)
861+
throw new ConflictError(`Cannot cancel already cancelled run: ${runId}`)
856862
}
857863
const wasPending = run.status === 'pending'
858864
const cancelled = await storage.cancelRun(runId, new Date().toISOString())
859865

860866
if (!cancelled) {
861867
// Run transitioned to a terminal state between the check and the update
862868
const current = await getRunOrThrow(runId)
863-
throw new Error(
869+
throw new ConflictError(
864870
`Cannot cancel run ${runId}: status changed to ${current.status}`,
865871
)
866872
}
@@ -882,10 +888,10 @@ function createDurablyInstance<
882888
async deleteRun(runId: string): Promise<void> {
883889
const run = await getRunOrThrow(runId)
884890
if (run.status === 'pending') {
885-
throw new Error(`Cannot delete pending run: ${runId}`)
891+
throw new ConflictError(`Cannot delete pending run: ${runId}`)
886892
}
887893
if (run.status === 'leased') {
888-
throw new Error(`Cannot delete leased run: ${runId}`)
894+
throw new ConflictError(`Cannot delete leased run: ${runId}`)
889895
}
890896
await storage.deleteRun(runId)
891897

packages/durably/src/errors.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,43 @@ export class LeaseLostError extends Error {
2020
}
2121
}
2222

23+
/**
24+
* Base class for errors that map to specific HTTP status codes.
25+
* Used by the HTTP handler to return appropriate responses.
26+
*/
27+
export class DurablyError extends Error {
28+
readonly statusCode: number
29+
constructor(message: string, statusCode: number) {
30+
super(message)
31+
this.name = 'DurablyError'
32+
this.statusCode = statusCode
33+
}
34+
}
35+
36+
/** 404 — Resource not found */
37+
export class NotFoundError extends DurablyError {
38+
constructor(message: string) {
39+
super(message, 404)
40+
this.name = 'NotFoundError'
41+
}
42+
}
43+
44+
/** 400 — Invalid input or request */
45+
export class ValidationError extends DurablyError {
46+
constructor(message: string) {
47+
super(message, 400)
48+
this.name = 'ValidationError'
49+
}
50+
}
51+
52+
/** 409 — Operation conflicts with current state */
53+
export class ConflictError extends DurablyError {
54+
constructor(message: string) {
55+
super(message, 409)
56+
this.name = 'ConflictError'
57+
}
58+
}
59+
2360
/**
2461
* Extract error message from unknown error
2562
*/

packages/durably/src/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function jsonResponse(data: unknown, status = 200): Response {
2929
*/
3030
export function errorResponse(
3131
message: string,
32-
status: 400 | 404 | 500 = 500,
32+
status: 400 | 404 | 409 | 500 = 500,
3333
): Response {
3434
return jsonResponse({ error: message }, status)
3535
}

packages/durably/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ export type {
6969
} from './storage'
7070

7171
// Errors
72-
export { CancelledError, LeaseLostError } from './errors'
72+
export {
73+
CancelledError,
74+
ConflictError,
75+
DurablyError,
76+
LeaseLostError,
77+
NotFoundError,
78+
ValidationError,
79+
} from './errors'
7380

7481
// Server
7582
export { createDurablyHandler } from './server'

packages/durably/src/job.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type z, prettifyError } from 'zod'
22
import type { JobDefinition } from './define-job'
3+
import { ValidationError } from './errors'
34
import type { EventEmitter, LogData, ProgressData } from './events'
45
import type { Run, RunFilter, Store } from './storage'
56

@@ -17,7 +18,9 @@ export function validateJobInputOrThrow<T>(
1718
const result = schema.safeParse(input)
1819
if (!result.success) {
1920
const prefix = context ? `${context}: ` : ''
20-
throw new Error(`${prefix}Invalid input: ${prettifyError(result.error)}`)
21+
throw new ValidationError(
22+
`${prefix}Invalid input: ${prettifyError(result.error)}`,
23+
)
2124
}
2225
return result.data
2326
}

packages/durably/src/server.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Durably } from './durably'
2+
import { DurablyError, getErrorMessage } from './errors'
23
import type { AnyEventInput } from './events'
34
import {
45
errorResponse,
5-
getErrorMessage,
66
getRequiredQueryParam,
77
jsonResponse,
88
successResponse,
@@ -267,14 +267,20 @@ export function createDurablyHandler<
267267

268268
// --- Shared helpers ---
269269

270-
/** Wrap handler with try/catch that re-throws Response and catches everything else as 500 */
270+
/** Wrap handler with try/catch that maps DurablyError to proper HTTP status */
271271
async function withErrorHandling(
272272
fn: () => Promise<Response>,
273273
): Promise<Response> {
274274
try {
275275
return await fn()
276276
} catch (error) {
277277
if (error instanceof Response) throw error
278+
if (error instanceof DurablyError) {
279+
return errorResponse(
280+
error.message,
281+
error.statusCode as 400 | 404 | 409 | 500,
282+
)
283+
}
278284
return errorResponse(getErrorMessage(error), 500)
279285
}
280286
}
@@ -323,14 +329,11 @@ export function createDurablyHandler<
323329
await auth.onTrigger(ctx as TContext, body)
324330
}
325331

326-
const run = await job.trigger(
327-
(body.input ?? {}) as Record<string, unknown>,
328-
{
329-
idempotencyKey: body.idempotencyKey,
330-
concurrencyKey: body.concurrencyKey,
331-
labels: body.labels,
332-
},
333-
)
332+
const run = await job.trigger(body.input as Record<string, unknown>, {
333+
idempotencyKey: body.idempotencyKey,
334+
concurrencyKey: body.concurrencyKey,
335+
labels: body.labels,
336+
})
334337

335338
const response: TriggerResponse = { runId: run.id }
336339
return jsonResponse(response)

packages/durably/tests/shared/server.shared.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ export function createServerTests(createDialect: () => Dialect) {
574574
expect(body.error).toBe('runId query parameter is required')
575575
})
576576

577-
it('returns 500 when retriggering a pending run', async () => {
577+
it('returns 409 when retriggering a pending run', async () => {
578578
const d = durably.register({
579579
job: defineJob({
580580
name: 'retrigger-pending-test',
@@ -590,7 +590,7 @@ export function createServerTests(createDialect: () => Dialect) {
590590
)
591591

592592
const response = await handler.handle(request, '/api/durably')
593-
expect(response.status).toBe(500)
593+
expect(response.status).toBe(409)
594594
})
595595
})
596596

@@ -632,7 +632,7 @@ export function createServerTests(createDialect: () => Dialect) {
632632
expect(body.error).toBe('runId query parameter is required')
633633
})
634634

635-
it('returns 500 when cancelling completed run', async () => {
635+
it('returns 409 when cancelling completed run', async () => {
636636
const d = durably.register({
637637
job: defineJob({
638638
name: 'cancel-completed-test',
@@ -657,7 +657,7 @@ export function createServerTests(createDialect: () => Dialect) {
657657
)
658658

659659
const response = await handler.handle(request, '/api/durably')
660-
expect(response.status).toBe(500)
660+
expect(response.status).toBe(409)
661661
})
662662
})
663663

0 commit comments

Comments
 (0)