diff --git a/README.md b/README.md index 439b0dc..557dbb4 100644 --- a/README.md +++ b/README.md @@ -261,9 +261,8 @@ disposer.defer(() => { - `flow` - `dispose` - `gen` -- `retryOptions` +- `createRetryPolicy` - `CancellationError` -- `ConfigurationError` - `TimeoutError` - `RetryExhaustedError` - `UnhandledException` @@ -285,7 +284,31 @@ disposer.defer(() => { - `flow` requires at least one `$exit(...)` path; otherwise it throws. - Control outcomes have precedence over mapped catch results in racing scenarios. - `wrap` is only available from `try$.wrap(...)` and can be chained as `.wrap().wrap()`. -- Invalid builder order/config usage throws `ConfigurationError`. +- Programmer-error paths throw `Panic`, not a returned error value. +- `Panic` exposes a `code` for machine-readable diagnostics. + +### Panic codes + +- `WRAP_UNAVAILABLE` +- `WRAP_INVALID_HANDLER` +- `RUN_SYNC_UNAVAILABLE` +- `RUN_SYNC_INVALID_INPUT` +- `FLOW_NO_EXIT` +- `GEN_UNAVAILABLE` +- `GEN_INVALID_FACTORY` +- `RUN_SYNC_WRAPPED_RESULT_PROMISE` +- `RUN_SYNC_TRY_PROMISE` +- `RUN_SYNC_CATCH_PROMISE` +- `RUN_SYNC_ASYNC_RETRY_POLICY` +- `RUN_CATCH_HANDLER_THROW` +- `RUN_CATCH_HANDLER_REJECT` +- `RUN_SYNC_CATCH_HANDLER_THROW` +- `ALL_CATCH_HANDLER_THROW` +- `ALL_CATCH_HANDLER_REJECT` +- `TASK_INVALID_HANDLER` +- `TASK_SELF_REFERENCE` +- `TASK_UNKNOWN_REFERENCE` +- `UNREACHABLE_RETRY_POLICY_BACKOFF` ## Contributing diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 4c64a53..3c8701e 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -7,6 +7,11 @@ class PermissionDeniedError extends Error {} class NetworkError extends Error {} class RemoteServiceError extends Error {} +function expectPanic(error: unknown, code: try$.PanicCode) { + expect(error).toBeInstanceOf(try$.Panic) + expect((error as try$.Panic).code).toBe(code) +} + function runCacheFlow(cachedValue: string | null) { return try$.flow({ a() { @@ -44,11 +49,16 @@ describe("runSync", () => { expect(result).toBeInstanceOf(try$.UnhandledException) }) - it("throws ConfigurationError when sync run receives a Promise-returning function via unsafe cast", () => { + it("throws Panic when sync run receives a Promise-returning function via unsafe cast", () => { const unsafeRun = try$.runSync as unknown as (tryFn: () => number) => number const unsafeTry = (() => Promise.resolve(42)) as unknown as () => number - expect(() => unsafeRun(unsafeTry)).toThrow(try$.ConfigurationError) + try { + unsafeRun(unsafeTry) + expect.unreachable("should have thrown") + } catch (error) { + expectPanic(error, "RUN_SYNC_TRY_PROMISE") + } }) }) @@ -65,7 +75,7 @@ describe("runSync", () => { }) it("throws Panic when catch throws", () => { - expect(() => + try { try$.runSync({ catch: () => { throw new Error("catch failed") @@ -74,7 +84,10 @@ describe("runSync", () => { throw new Error("boom") }, }) - ).toThrow(try$.Panic) + expect.unreachable("should have thrown") + } catch (error) { + expectPanic(error, "RUN_SYNC_CATCH_HANDLER_THROW") + } }) it("supports multiple mapped error variants in sync object form", () => { @@ -169,7 +182,7 @@ describe("run", () => { await result throw new Error("Expected Panic rejection") } catch (error) { - expect(error).toBeInstanceOf(try$.Panic) + expectPanic(error, "RUN_CATCH_HANDLER_REJECT") } }) @@ -258,12 +271,17 @@ describe("builder helpers", () => { expect(result).toBe(42) }) - it("throws ConfigurationError when runSync is called after retry via unsafe cast", () => { + it("throws Panic when runSync is called after retry via unsafe cast", () => { const unsafeBuilder = try$.retry(3) as unknown as { runSync: typeof try$.runSync } - expect(() => unsafeBuilder.runSync(() => 42)).toThrow(try$.ConfigurationError) + try { + unsafeBuilder.runSync(() => 42) + expect.unreachable("should have thrown") + } catch (error) { + expectPanic(error, "RUN_SYNC_UNAVAILABLE") + } }) it("supports multiple wraps in top-level wrap chain", async () => { @@ -288,12 +306,17 @@ describe("builder helpers", () => { expect(events).toEqual(["outer-before", "inner-before", "inner-after", "outer-after"]) }) - it("throws ConfigurationError when wrap is called after retry", () => { + it("throws Panic when wrap is called after retry", () => { const retried = try$.retry(3) as unknown as { wrap: (fn: Parameters[0]) => unknown } - expect(() => retried.wrap((ctx, next) => next(ctx))).toThrow(try$.ConfigurationError) + try { + retried.wrap((ctx, next) => next(ctx)) + expect.unreachable("should have thrown") + } catch (error) { + expectPanic(error, "WRAP_UNAVAILABLE") + } }) it("applies wrap around all", async () => { @@ -372,7 +395,7 @@ describe("builder helpers", () => { }) expect.unreachable("should have thrown") } catch (error) { - expect((error as Error).message).toBe("Flow completed without exit") + expectPanic(error, "FLOW_NO_EXIT") expect(wrapCalls).toBe(1) } }) @@ -411,16 +434,19 @@ describe("builder helpers", () => { expect(wrapCalls).toBe(1) }) - it("throws ConfigurationError when gen is called after timeout via unsafe cast", () => { + it("throws Panic when gen is called after timeout via unsafe cast", () => { const unsafeBuilder = try$.timeout(10) as unknown as { gen: typeof try$.gen } - expect(() => + try { unsafeBuilder.gen(function* (use) { return yield* use(1) }) - ).toThrow(try$.ConfigurationError) + expect.unreachable("should have thrown") + } catch (error) { + expectPanic(error, "GEN_UNAVAILABLE") + } }) it("keeps root run isolated from retry chains", async () => { @@ -478,7 +504,7 @@ describe("flow", () => { }) expect.unreachable("should have thrown") } catch (error) { - expect((error as Error).message).toBe("Flow completed without exit") + expectPanic(error, "FLOW_NO_EXIT") } }) @@ -625,7 +651,7 @@ describe("flow", () => { }) expect.unreachable("should have thrown") } catch (error) { - expect((error as Error).message).toContain("cannot await its own result") + expectPanic(error, "TASK_SELF_REFERENCE") } }) @@ -640,7 +666,7 @@ describe("flow", () => { }) expect.unreachable("should have thrown") } catch (error) { - expect((error as Error).message).toContain("Unknown task") + expectPanic(error, "TASK_UNKNOWN_REFERENCE") } }) }) @@ -875,7 +901,7 @@ describe("all", () => { ) expect.unreachable("should have thrown") } catch (error) { - expect(error).toBeInstanceOf(try$.Panic) + expectPanic(error, "ALL_CATCH_HANDLER_THROW") } }) @@ -896,7 +922,7 @@ describe("all", () => { ) expect.unreachable("should have thrown") } catch (error) { - expect(error).toBeInstanceOf(try$.Panic) + expectPanic(error, "ALL_CATCH_HANDLER_REJECT") } }) diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index aeb23e6..c6f9dda 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -150,6 +150,24 @@ describe("type inference", () => { }) }) + describe("panic", () => { + it("exports PanicCode and removes ConfigurationError", () => { + if (typecheckOnly()) { + const panic = new try$.Panic("FLOW_NO_EXIT") + const taskCodes: try$.PanicCode[] = [ + "FLOW_NO_EXIT", + "TASK_SELF_REFERENCE", + "TASK_UNKNOWN_REFERENCE", + ] + type _assert = Expect> + void taskCodes + + // @ts-expect-error -- ConfigurationError was removed in favor of coded Panic + void try$.ConfigurationError + } + }) + }) + describe("with retry", () => { it("constant zero-delay retry run returns Promise union", () => { const result = try$.retry(3).run(() => 42) diff --git a/src/index.ts b/src/index.ts index 6a4f01c..97e4c85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,12 @@ import { RunBuilder, createWrappedBuilder } from "./lib/builder" import { CancellationError, - ConfigurationError, Panic, RetryExhaustedError, TimeoutError, UnhandledException, } from "./lib/errors" -import { retryOptions } from "./lib/modifiers/retry" +import { createRetryPolicy } from "./lib/modifiers/retry" const root: RunBuilder = new RunBuilder() @@ -25,15 +24,9 @@ export const gen: RunBuilder["gen"] = root.gen.bind(root) export { dispose } from "./lib/dispose" -export { retryOptions } -export { - CancellationError, - ConfigurationError, - Panic, - RetryExhaustedError, - TimeoutError, - UnhandledException, -} +export { createRetryPolicy } +export { CancellationError, Panic, RetryExhaustedError, TimeoutError, UnhandledException } +export type { PanicCode } from "./lib/errors" export type { AllSettledResult, diff --git a/src/lib/__tests__/utils.test.ts b/src/lib/__tests__/utils.test.ts index 566bef9..9dd4552 100644 --- a/src/lib/__tests__/utils.test.ts +++ b/src/lib/__tests__/utils.test.ts @@ -6,7 +6,7 @@ import { TimeoutError, UnhandledException, } from "../errors" -import { assertUnreachable, checkIsControlError, checkIsPromiseLike } from "../utils" +import { assertUnreachable, checkIsControlError, checkIsPromiseLike, invariant } from "../utils" describe("checkIsControlError", () => { it("returns true for CancellationError", () => { @@ -14,7 +14,7 @@ describe("checkIsControlError", () => { }) it("returns true for Panic", () => { - expect(checkIsControlError(new Panic())).toBe(true) + expect(checkIsControlError(new Panic("RUN_CATCH_HANDLER_THROW"))).toBe(true) }) it("returns true for TimeoutError", () => { @@ -58,8 +58,34 @@ describe("checkIsPromiseLike", () => { }) }) +describe("invariant", () => { + it("does nothing when the condition is truthy", () => { + expect(() => { + invariant(true, new Error("boom")) + }).not.toThrow() + }) + + it("throws the provided error when the condition is falsy", () => { + const error = new Panic("RUN_SYNC_TRY_PROMISE") + + expect(() => { + invariant(false, error) + }).toThrow(error) + }) +}) + describe("assertUnreachable", () => { it("throws with the unreachable value", () => { - expect(() => assertUnreachable("unexpected" as never)).toThrow("Unreachable case: unexpected") + let error: unknown + + try { + assertUnreachable("unexpected" as never, "UNREACHABLE_RETRY_POLICY_BACKOFF") + } catch (caughtError) { + error = caughtError + } + + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("UNREACHABLE_RETRY_POLICY_BACKOFF") + expect((error as Error).message).toBe("Unreachable case: unexpected") }) }) diff --git a/src/lib/builder.ts b/src/lib/builder.ts index dc130d7..0f28f08 100644 --- a/src/lib/builder.ts +++ b/src/lib/builder.ts @@ -1,5 +1,6 @@ import type { CancellationError, + PanicMessages, RetryExhaustedError, TimeoutError, UnhandledException, @@ -14,6 +15,7 @@ import type { } from "./executors/all" import type { FlowResult, InferredFlowTaskContext } from "./executors/flow" import type { GenResult, GenUse } from "./executors/gen" +import type { BuilderConfig, TimeoutOptions, WrapFn } from "./types/builder" import type { BuilderState, DefaultBuilderState, @@ -25,22 +27,17 @@ import type { } from "./types/core" import type { RetryOptions } from "./types/retry" import type { AsyncRunInput, RunTryFn } from "./types/run" -import { ConfigurationError as ConfigurationErrorClass } from "./errors" +import { Panic } from "./errors" import { executeAll } from "./executors/all" import { executeAllSettled } from "./executors/all-settled" import { executeFlow } from "./executors/flow" import { executeGen } from "./executors/gen" import { executeRun } from "./executors/run" import { executeRunSync, type SyncRunInput, type SyncRunTryFn } from "./executors/run-sync" -import { normalizeRetryPolicy } from "./modifiers/retry" +import { createRetryPolicy } from "./modifiers/retry" import { normalizeTimeoutOptions } from "./modifiers/timeout" import { executeWithWraps } from "./modifiers/wrap" -import { - BuilderErrors, - type BuilderConfig, - type TimeoutOptions, - type WrapFn, -} from "./types/builder" +import { invariant } from "./utils" type ConfigRunErrors = RetryExhaustedError | TimeoutError | CancellationError type WithRetry = SetTryCtxFeature @@ -74,7 +71,7 @@ export class RunBuilder< return new RunBuilder( { ...this.#config, - retry: normalizeRetryPolicy(policy), + retry: createRetryPolicy(policy), }, { ...this.#state, @@ -117,22 +114,13 @@ export class RunBuilder< } wrap( - fn: State["canWrap"] extends true ? WrapFn : typeof BuilderErrors.WRAP_UNAVAILABLE + fn: State["canWrap"] extends true ? WrapFn : PanicMessages["WRAP_UNAVAILABLE"] ): RunBuilder> wrap( - fn: WrapFn | typeof BuilderErrors.WRAP_UNAVAILABLE + fn: WrapFn | PanicMessages["WRAP_UNAVAILABLE"] ): RunBuilder> { - if (!this.#state.canWrap) { - throw new ConfigurationErrorClass({ - message: BuilderErrors.WRAP_UNAVAILABLE, - }) - } - - if (typeof fn !== "function") { - throw new ConfigurationErrorClass({ - message: BuilderErrors.WRAP_UNAVAILABLE, - }) - } + invariant(this.#state.canWrap, new Panic("WRAP_UNAVAILABLE")) + invariant(typeof fn === "function", new Panic("WRAP_INVALID_HANDLER")) return new RunBuilder( { @@ -155,27 +143,18 @@ export class RunBuilder< runSync( tryFn: State["canSync"] extends true ? SyncRunTryFn> - : typeof BuilderErrors.RUN_SYNC_UNAVAILABLE + : PanicMessages["RUN_SYNC_UNAVAILABLE"] ): T | UnhandledException | E runSync( input: State["canSync"] extends true ? SyncRunInput> - : typeof BuilderErrors.RUN_SYNC_UNAVAILABLE + : PanicMessages["RUN_SYNC_UNAVAILABLE"] ): T | C | E runSync( - input: SyncRunInput> | typeof BuilderErrors.RUN_SYNC_UNAVAILABLE + input: SyncRunInput> | PanicMessages["RUN_SYNC_UNAVAILABLE"] ) { - if (!this.#state.canSync) { - throw new ConfigurationErrorClass({ - message: BuilderErrors.RUN_SYNC_UNAVAILABLE, - }) - } - - if (typeof input === "string") { - throw new ConfigurationErrorClass({ - message: BuilderErrors.RUN_SYNC_UNAVAILABLE, - }) - } + invariant(this.#state.canSync, new Panic("RUN_SYNC_UNAVAILABLE")) + invariant(typeof input !== "string", new Panic("RUN_SYNC_INVALID_INPUT")) return executeRunSync(this.#config, input) } @@ -202,19 +181,10 @@ export class RunBuilder< gen( factory: State["canSync"] extends true ? (useFn: GenUse) => Generator - : typeof BuilderErrors.GEN_UNAVAILABLE + : PanicMessages["GEN_UNAVAILABLE"] ) { - if (!this.#state.canSync) { - throw new ConfigurationErrorClass({ - message: BuilderErrors.GEN_UNAVAILABLE, - }) - } - - if (typeof factory !== "function") { - throw new ConfigurationErrorClass({ - message: BuilderErrors.GEN_UNAVAILABLE, - }) - } + invariant(this.#state.canSync, new Panic("GEN_UNAVAILABLE")) + invariant(typeof factory === "function", new Panic("GEN_INVALID_FACTORY")) return executeWithWraps( this.#config.wraps, @@ -231,6 +201,8 @@ export function createWrappedBuilder( DefaultTryCtxProperties, SetBuilderState > { + invariant(typeof fn === "function", new Panic("WRAP_INVALID_HANDLER")) + return new RunBuilder( { wraps: [fn] }, { diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 2c64559..fb1407a 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,58 +1,88 @@ -type ExecutionErrorOptions = { - cause?: unknown - message?: string -} +export type PanicCode = + | "ALL_CATCH_HANDLER_REJECT" + | "ALL_CATCH_HANDLER_THROW" + | "FLOW_NO_EXIT" + | "GEN_INVALID_FACTORY" + | "GEN_UNAVAILABLE" + | "RUN_CATCH_HANDLER_REJECT" + | "RUN_CATCH_HANDLER_THROW" + | "RUN_SYNC_ASYNC_RETRY_POLICY" + | "RUN_SYNC_CATCH_HANDLER_THROW" + | "RUN_SYNC_CATCH_PROMISE" + | "RUN_SYNC_INVALID_INPUT" + | "RUN_SYNC_TRY_PROMISE" + | "RUN_SYNC_UNAVAILABLE" + | "RUN_SYNC_WRAPPED_RESULT_PROMISE" + | "TASK_INVALID_HANDLER" + | "TASK_SELF_REFERENCE" + | "TASK_UNKNOWN_REFERENCE" + | "UNREACHABLE_RETRY_POLICY_BACKOFF" + | "WRAP_INVALID_HANDLER" + | "WRAP_UNAVAILABLE" -export class CancellationError extends Error { - constructor(options: ExecutionErrorOptions = {}) { - const { cause, message } = options +export const PanicMessages = { + ALL_CATCH_HANDLER_REJECT: "Panic: all() catch handler rejected", + ALL_CATCH_HANDLER_THROW: "Panic: all() catch handler threw", + FLOW_NO_EXIT: "flow() requires at least one task to call $exit().", + GEN_INVALID_FACTORY: "gen() expects a generator factory function.", + GEN_UNAVAILABLE: + "gen() is unavailable after retry(), timeout(), or signal(). Start a new builder chain or use top-level gen().", + RUN_CATCH_HANDLER_REJECT: "Panic: run() catch handler rejected", + RUN_CATCH_HANDLER_THROW: "Panic: run() catch handler threw", + RUN_SYNC_ASYNC_RETRY_POLICY: "This retry policy may run asynchronously. Use run() instead.", + RUN_SYNC_CATCH_HANDLER_THROW: "Panic: runSync() catch handler threw", + RUN_SYNC_CATCH_PROMISE: "runSync() catch cannot return a Promise. Use run() instead.", + RUN_SYNC_INVALID_INPUT: "runSync() expects a function or { try, catch } input.", + RUN_SYNC_TRY_PROMISE: "runSync() cannot handle Promise values. Use run() instead.", + RUN_SYNC_UNAVAILABLE: + "runSync() is unavailable after retry(), timeout(), or signal(). Use run() or start a new builder chain.", + RUN_SYNC_WRAPPED_RESULT_PROMISE: + "Wrapped runSync() execution returned a Promise. Use run() instead.", + TASK_INVALID_HANDLER: "Task runner expected a function handler.", + TASK_SELF_REFERENCE: "Task cannot await its own result.", + TASK_UNKNOWN_REFERENCE: "Task attempted to read an unknown task result.", + UNREACHABLE_RETRY_POLICY_BACKOFF: "Panic: unreachable retry policy backoff", + WRAP_INVALID_HANDLER: "wrap() expects a function.", + WRAP_UNAVAILABLE: "wrap() is unavailable after retry(), timeout(), or signal().", +} as const satisfies Record +export type PanicMessages = typeof PanicMessages - super(message ?? "Execution was cancelled", cause === undefined ? undefined : { cause }) +export class CancellationError extends Error { + constructor(message = "Execution was cancelled", options?: ErrorOptions) { + super(message, options) this.name = "CancellationError" } } export class TimeoutError extends Error { - constructor(options: ExecutionErrorOptions = {}) { - const { cause, message } = options - - super(message ?? "Execution timed out", cause === undefined ? undefined : { cause }) + constructor(message = "Execution timed out", options?: ErrorOptions) { + super(message, options) this.name = "TimeoutError" } } export class RetryExhaustedError extends Error { - constructor(options: ExecutionErrorOptions = {}) { - const { cause, message } = options - - super(message ?? "Retry attempts exhausted", cause === undefined ? undefined : { cause }) + constructor(message = "Retry attempts exhausted", options?: ErrorOptions) { + super(message, options) this.name = "RetryExhaustedError" } } -export class ConfigurationError extends Error { - constructor(options: ExecutionErrorOptions = {}) { - const { cause, message } = options - - super(message ?? "Invalid execution configuration", cause === undefined ? undefined : { cause }) - this.name = "ConfigurationError" - } -} - export class UnhandledException extends Error { - constructor(options: ExecutionErrorOptions = {}) { - const { cause, message } = options - - super(message ?? "Unhandled exception", cause === undefined ? undefined : { cause }) + constructor(message = "Unhandled exception", options?: ErrorOptions) { + super(message, options) this.name = "UnhandledException" } } export class Panic extends Error { - constructor(options: ExecutionErrorOptions = {}) { - const { cause, message } = options + readonly code: PanicCode + + constructor(code: PanicCode, options: ErrorOptions & { message?: string } = {}) { + const { message, ...errorOptions } = options - super(message ?? "Panic: catch handler failed", cause === undefined ? undefined : { cause }) + super(message ?? PanicMessages[code], errorOptions) + this.code = code this.name = "Panic" } } diff --git a/src/lib/executors/__tests__/all.test.ts b/src/lib/executors/__tests__/all.test.ts index c548882..9658ac0 100644 --- a/src/lib/executors/__tests__/all.test.ts +++ b/src/lib/executors/__tests__/all.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { TimeoutError } from "../../errors" +import { CancellationError, Panic, TimeoutError } from "../../errors" import { sleep } from "../../utils" import { executeAll } from "../all" @@ -156,8 +156,8 @@ describe("executeAll", () => { ) expect.unreachable("should have thrown") } catch (error) { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain("Unknown task") + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("TASK_UNKNOWN_REFERENCE") } }) @@ -173,8 +173,23 @@ describe("executeAll", () => { ) expect.unreachable("should have thrown") } catch (error) { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain("cannot await its own result") + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("TASK_SELF_REFERENCE") + } + }) + + it("rejects with TASK_INVALID_HANDLER when a task is not a function", async () => { + try { + await executeAll({}, { + a: 123, + } as unknown as { + a(): number + }) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("TASK_INVALID_HANDLER") + expect((error as Error).message).toContain('Task "a" is not a function') } }) }) @@ -219,6 +234,29 @@ describe("executeAll", () => { expect((error as Error).message).toBe("a boom") } }) + + it("rejects with Panic when async catch rejects", async () => { + try { + await executeAll( + {}, + { + a() { + throw new Error("boom") + }, + }, + { + catch: async () => { + await Promise.resolve() + throw new Error("catch failed") + }, + } + ) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("ALL_CATCH_HANDLER_REJECT") + } + }) }) describe("return values", () => { @@ -340,6 +378,33 @@ describe("executeAll", () => { expect(error).toBeInstanceOf(TimeoutError) } }) + + it("rejects with TimeoutError when timeout expires during catch execution", async () => { + try { + await executeAll( + { + timeout: { + ms: 5, + scope: "total", + }, + }, + { + a() { + throw new Error("boom") + }, + }, + { + catch: async () => { + await sleep(20) + return "mapped" + }, + } + ) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(TimeoutError) + } + }) }) describe("abort signal ($signal)", () => { @@ -406,6 +471,36 @@ describe("executeAll", () => { expect(taskSignalAborted).toBe(true) }) + it("rejects with CancellationError when signal aborts during catch execution", async () => { + const controller = new AbortController() + + const promise = executeAll( + { signals: [controller.signal] }, + { + a() { + throw new Error("boom") + }, + }, + { + catch: async () => { + await sleep(20) + return "mapped" + }, + } + ) + + setTimeout(() => { + controller.abort(new Error("external abort")) + }, 5) + + try { + await promise + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(CancellationError) + } + }) + it("handles already-aborted external signal", async () => { const controller = new AbortController() controller.abort(new Error("pre-aborted")) diff --git a/src/lib/executors/__tests__/flow.test.ts b/src/lib/executors/__tests__/flow.test.ts index 206bb6c..c0e7db4 100644 --- a/src/lib/executors/__tests__/flow.test.ts +++ b/src/lib/executors/__tests__/flow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { CancellationError, RetryExhaustedError, TimeoutError } from "../../errors" +import { CancellationError, Panic, RetryExhaustedError, TimeoutError } from "../../errors" import { executeFlow } from "../flow" function sleep(ms: number): Promise { @@ -25,7 +25,8 @@ describe("executeFlow", () => { ) expect.unreachable("should have thrown") } catch (error) { - expect((error as Error).message).toBe("Flow completed without exit") + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("FLOW_NO_EXIT") } }) @@ -46,6 +47,36 @@ describe("executeFlow", () => { expect(result).toBe("done") }) + it("treats $exit(new TimeoutError()) as a returned exit value", async () => { + const timeout = new TimeoutError("returned value") + + const result = await executeFlow( + {}, + { + a() { + return this.$exit(timeout) + }, + } + ) + + expect(result).toBe(timeout) + }) + + it("treats $exit(new CancellationError()) as a returned exit value", async () => { + const cancellation = new CancellationError("returned value") + + const result = await executeFlow( + {}, + { + a() { + return this.$exit(cancellation) + }, + } + ) + + expect(result).toBe(cancellation) + }) + it("returns early when a dependent task reads a task that already exited", async () => { const result = await executeFlow( {}, @@ -168,6 +199,8 @@ describe("executeFlow", () => { ) expect.unreachable("should have thrown") } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("TASK_SELF_REFERENCE") expect((error as Error).message).toContain("cannot await its own result") } }) @@ -186,10 +219,27 @@ describe("executeFlow", () => { ) expect.unreachable("should have thrown") } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("TASK_UNKNOWN_REFERENCE") expect((error as Error).message).toContain("Unknown task") } }) + it("rejects with TASK_INVALID_HANDLER when a task is not a function", async () => { + try { + await executeFlow({}, { + a: 123, + } as unknown as { + a(): number + }) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("TASK_INVALID_HANDLER") + expect((error as Error).message).toContain('Task "a" is not a function') + } + }) + it("applies wrap middleware around flow execution", async () => { let wrapCalls = 0 @@ -328,7 +378,8 @@ describe("executeFlow", () => { ) expect.unreachable("should have thrown") } catch (error) { - expect((error as Error).message).toContain("Unknown task") + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("TASK_UNKNOWN_REFERENCE") } }) diff --git a/src/lib/executors/__tests__/retry.test.ts b/src/lib/executors/__tests__/retry.test.ts index 678a113..7f016ed 100644 --- a/src/lib/executors/__tests__/retry.test.ts +++ b/src/lib/executors/__tests__/retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { ConfigurationError, RetryExhaustedError, TimeoutError } from "../../errors" +import { Panic, RetryExhaustedError, TimeoutError } from "../../errors" import { executeRun } from "../run" import { executeRunSync } from "../run-sync" @@ -172,8 +172,8 @@ describe("executeRun retry", () => { expect(mapped).toBe(false) }) - it("throws ConfigurationError when sync runner is used with async-required retry policy", () => { - expect(() => + it("throws Panic when sync runner is used with async-required retry policy", () => { + try { executeRunSync( { retry: { backoff: "linear", delayMs: 10, limit: 3 }, @@ -182,11 +182,15 @@ describe("executeRun retry", () => { throw new Error("boom") } ) - ).toThrow(ConfigurationError) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("RUN_SYNC_ASYNC_RETRY_POLICY") + } }) - it("throws ConfigurationError when sync runner is used with jittered constant retry", () => { - expect(() => + it("throws Panic when sync runner is used with jittered constant retry", () => { + try { executeRunSync( { retry: { backoff: "constant", delayMs: 0, jitter: true, limit: 3 }, @@ -195,7 +199,11 @@ describe("executeRun retry", () => { throw new Error("boom") } ) - ).toThrow(ConfigurationError) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("RUN_SYNC_ASYNC_RETRY_POLICY") + } }) it("applies linear backoff delays between retries", async () => { diff --git a/src/lib/executors/__tests__/run-sync.test.ts b/src/lib/executors/__tests__/run-sync.test.ts index fa04bd2..51c4d24 100644 --- a/src/lib/executors/__tests__/run-sync.test.ts +++ b/src/lib/executors/__tests__/run-sync.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test" import type { TryCtx } from "../../types/core" -import { CancellationError, ConfigurationError, Panic, UnhandledException } from "../../errors" -import { executeRunSync } from "../run-sync" +import { CancellationError, Panic, UnhandledException } from "../../errors" +import { executeRunSync, runSync } from "../run-sync" describe("executeRunSync", () => { describe("function form", () => { @@ -19,10 +19,16 @@ describe("executeRunSync", () => { expect(result).toBeInstanceOf(UnhandledException) }) - it("throws ConfigurationError when sync runner returns a promise via unsafe cast", () => { + it("throws Panic when sync runner returns a promise via unsafe cast", () => { const unsafeSyncFn = (() => Promise.resolve("ok")) as unknown as () => string - expect(() => executeRunSync({}, unsafeSyncFn)).toThrow(ConfigurationError) + try { + executeRunSync({}, unsafeSyncFn) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("RUN_SYNC_TRY_PROMISE") + } }) }) @@ -42,7 +48,7 @@ describe("executeRunSync", () => { }) it("throws Panic when catch throws", () => { - expect(() => + try { executeRunSync( {}, { @@ -54,7 +60,28 @@ describe("executeRunSync", () => { }, } ) - ).toThrow(Panic) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("RUN_SYNC_CATCH_HANDLER_THROW") + } + }) + + it("rethrows RUN_SYNC_CATCH_PROMISE unchanged when catch returns a promise", () => { + const unsafeCatch = (() => Promise.resolve("mapped")) as unknown as (error: unknown) => string + + try { + runSync({ + catch: unsafeCatch, + try: () => { + throw new Error("boom") + }, + }) + expect.unreachable("should have thrown") + } catch (error) { + expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("RUN_SYNC_CATCH_PROMISE") + } }) }) diff --git a/src/lib/executors/__tests__/run.test.ts b/src/lib/executors/__tests__/run.test.ts index 97db9e7..370a176 100644 --- a/src/lib/executors/__tests__/run.test.ts +++ b/src/lib/executors/__tests__/run.test.ts @@ -61,6 +61,7 @@ describe("executeRun", () => { throw new Error("Expected Panic rejection") } catch (error) { expect(error).toBeInstanceOf(Panic) + expect((error as Panic).code).toBe("RUN_CATCH_HANDLER_REJECT") } }) }) diff --git a/src/lib/executors/__tests__/shared.test.ts b/src/lib/executors/__tests__/shared.test.ts index 4265b2c..ef0ef93 100644 --- a/src/lib/executors/__tests__/shared.test.ts +++ b/src/lib/executors/__tests__/shared.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test" +import { Panic } from "../../errors" import { sleep } from "../../utils" import { TaskExecution } from "../shared" @@ -111,6 +112,7 @@ describe("TaskExecution", () => { } expect(result.a.status).toBe("rejected") - expect((result.a.reason as Error).message).toContain("Unknown task") + expect(result.a.reason).toBeInstanceOf(Panic) + expect((result.a.reason as Panic).code).toBe("TASK_UNKNOWN_REFERENCE") }) }) diff --git a/src/lib/executors/all.ts b/src/lib/executors/all.ts index 9bdef48..8454c33 100644 --- a/src/lib/executors/all.ts +++ b/src/lib/executors/all.ts @@ -7,7 +7,7 @@ import type { TaskValidation, } from "../types/all" import type { BuilderConfig } from "../types/builder" -import { Panic, TimeoutError } from "../errors" +import { CancellationError, Panic, TimeoutError } from "../errors" import { checkIsPromiseLike } from "../utils" import { BaseExecution } from "./base" import { TaskExecution } from "./shared" @@ -45,17 +45,33 @@ class AllExecution extends BaseExecution + try { - const mapped = catchFn(error, context) + mapped = catchFn(error, context) + } catch (catchError) { + throw new Panic("ALL_CATCH_HANDLER_THROW", { cause: catchError }) + } - if (checkIsPromiseLike(mapped)) { - return await mapped - } + if (checkIsPromiseLike(mapped)) { + try { + const raced = await this.race(mapped, error) - return mapped - } catch (catchError) { - throw new Panic({ cause: catchError }) + if (raced instanceof CancellationError || raced instanceof TimeoutError) { + throw raced + } + + return raced + } catch (catchError) { + if (catchError instanceof CancellationError || catchError instanceof TimeoutError) { + throw catchError + } + + throw new Panic("ALL_CATCH_HANDLER_REJECT", { cause: catchError }) + } } + + return mapped } } } diff --git a/src/lib/executors/base.ts b/src/lib/executors/base.ts index 684fea8..6303d26 100644 --- a/src/lib/executors/base.ts +++ b/src/lib/executors/base.ts @@ -37,14 +37,6 @@ export class RetryDirective { } } -function extractControlResult(value: unknown): CancellationError | TimeoutError | undefined { - if (value instanceof CancellationError || value instanceof TimeoutError) { - return value - } - - return undefined -} - export abstract class BaseExecution { protected readonly config: BuilderConfig protected readonly ctx: TryCtx @@ -102,12 +94,6 @@ export abstract class BaseExecution { return this.checkDidControlFail() ?? value } - protected static resolveRacedResult( - value: V | CancellationError | TimeoutError - ): V | CancellationError | TimeoutError { - return extractControlResult(value) ?? value - } - protected async race( promise: PromiseLike, cause?: unknown @@ -133,7 +119,12 @@ export abstract class BaseExecution { } const sleepResult = await this.race(sleep(delay)) - return extractControlResult(sleepResult) + + if (sleepResult instanceof CancellationError || sleepResult instanceof TimeoutError) { + return sleepResult + } + + return undefined } protected shouldAttemptRetry(error: unknown): boolean { diff --git a/src/lib/executors/flow.ts b/src/lib/executors/flow.ts index 403c5ec..a282bab 100644 --- a/src/lib/executors/flow.ts +++ b/src/lib/executors/flow.ts @@ -1,8 +1,8 @@ import type { ResultProxy, TaskRecord } from "../types/all" import type { BuilderConfig } from "../types/builder" import type { FlowResult, FlowTaskContext, InferredFlowTaskContext } from "../types/flow" -import { CancellationError, RetryExhaustedError, TimeoutError, UnhandledException } from "../errors" -import { checkIsControlError } from "../utils" +import { Panic, RetryExhaustedError, UnhandledException } from "../errors" +import { checkIsControlError, invariant } from "../utils" import { BaseExecution } from "./base" class FlowExitSignal extends Error { @@ -15,10 +15,6 @@ class FlowExitSignal extends Error { } } -function checkIsFlowExitSignal(value: unknown): value is FlowExitSignal { - return value instanceof FlowExitSignal -} - class FlowExecution { readonly #tasks: T readonly #taskNames: Array @@ -50,9 +46,9 @@ class FlowExecution { try { await Promise.all(promises) - throw new Error("Flow completed without exit") + throw new Panic("FLOW_NO_EXIT") } catch (error) { - if (checkIsFlowExitSignal(error)) { + if (error instanceof FlowExitSignal) { return error.value as FlowResult } @@ -62,11 +58,19 @@ class FlowExecution { #waitForResult(taskName: keyof T, requesterTaskName?: keyof T): Promise { if (requesterTaskName === taskName) { - return Promise.reject(new Error(`Task "${String(taskName)}" cannot await its own result`)) + return Promise.reject( + new Panic("TASK_SELF_REFERENCE", { + message: `Task "${String(taskName)}" cannot await its own result`, + }) + ) } if (!Object.hasOwn(this.#tasks, taskName)) { - return Promise.reject(new Error(`Unknown task "${String(taskName)}"`)) + return Promise.reject( + new Panic("TASK_UNKNOWN_REFERENCE", { + message: `Unknown task "${String(taskName)}"`, + }) + ) } if (this.#results.has(taskName)) { @@ -76,12 +80,14 @@ class FlowExecution { if (this.#errors.has(taskName)) { const resultError = this.#errors.get(taskName) - if (checkIsFlowExitSignal(resultError)) { + if (resultError instanceof FlowExitSignal) { return Promise.reject(resultError) } return Promise.reject( - resultError instanceof Error ? resultError : new UnhandledException({ cause: resultError }) + resultError instanceof Error + ? resultError + : new UnhandledException(undefined, { cause: resultError }) ) } @@ -130,9 +136,12 @@ class FlowExecution { try { const taskFn = this.#tasks[taskName] - if (typeof taskFn !== "function") { - throw new Error(`Task "${String(taskName)}" is not a function`) - } + invariant( + typeof taskFn === "function", + new Panic("TASK_INVALID_HANDLER", { + message: `Task "${String(taskName)}" is not a function`, + }) + ) const resultProxy = new Proxy({} as ResultProxy, { get: (_, referencedTaskName: string) => @@ -185,11 +194,11 @@ class FlowRunnerExecution extends BaseExecution extends BaseExecution extends BaseExecution | CancellationError | TimeoutError> { + async #executeAttempt(): Promise> { await using execution = new FlowExecution(this.signal.signal, this.#tasks) - return await this.race(execution.execute()) + return (await this.race(execution.execute())) as FlowResult } } diff --git a/src/lib/executors/gen.ts b/src/lib/executors/gen.ts index 0a2cd24..4de2e7d 100644 --- a/src/lib/executors/gen.ts +++ b/src/lib/executors/gen.ts @@ -26,7 +26,7 @@ async function executeAsyncGenerator( try { currentValue = await initialValue } catch (error) { - return new UnhandledException({ cause: error }) as GenErrors + return new UnhandledException(undefined, { cause: error }) as GenErrors } if (currentValue instanceof Error) { @@ -40,7 +40,7 @@ async function executeAsyncGenerator( try { step = iterator.next(currentValue) } catch (error) { - return new UnhandledException({ cause: error }) as GenErrors + return new UnhandledException(undefined, { cause: error }) as GenErrors } if (step.done) { @@ -49,7 +49,7 @@ async function executeAsyncGenerator( // oxlint-disable-next-line no-await-in-loop return (await step.value) as Awaited } catch (error) { - return new UnhandledException({ cause: error }) as GenErrors + return new UnhandledException(undefined, { cause: error }) as GenErrors } } @@ -61,7 +61,7 @@ async function executeAsyncGenerator( // oxlint-disable-next-line no-await-in-loop currentValue = await step.value } catch (error) { - return new UnhandledException({ cause: error }) as GenErrors + return new UnhandledException(undefined, { cause: error }) as GenErrors } } else { currentValue = step.value @@ -81,7 +81,7 @@ export function executeGen( try { iterator = factory(use) } catch (error) { - return new UnhandledException({ cause: error }) as GenResult + return new UnhandledException(undefined, { cause: error }) as GenResult } let currentValue: unknown = undefined @@ -93,7 +93,7 @@ export function executeGen( try { step = iterator.next(currentValue) } catch (error) { - return new UnhandledException({ cause: error }) as GenResult + return new UnhandledException(undefined, { cause: error }) as GenResult } if (step.done) { diff --git a/src/lib/executors/run-sync.ts b/src/lib/executors/run-sync.ts index 4f7b576..047fbbb 100644 --- a/src/lib/executors/run-sync.ts +++ b/src/lib/executors/run-sync.ts @@ -2,8 +2,8 @@ import type { BuilderConfig } from "../types/builder" import type { BaseTryCtx, NonPromise } from "../types/core" import type { RetryPolicy } from "../types/retry" import type { RunnerError } from "./base" -import { ConfigurationError, Panic, RetryExhaustedError, UnhandledException } from "../errors" -import { checkIsControlError, checkIsPromiseLike } from "../utils" +import { Panic, RetryExhaustedError, UnhandledException, type PanicCode } from "../errors" +import { checkIsControlError, checkIsPromiseLike, invariant } from "../utils" import { BaseExecution, RetryDirective } from "./base" export type SyncRunTryFn = (ctx: Ctx) => NonPromise @@ -28,10 +28,15 @@ export interface RunSyncOptions { export type RunSyncInput = RunSyncTryFn | RunSyncOptions -function throwIfPromiseLike(value: unknown, message: string): void { - if (checkIsPromiseLike(value)) { - throw new ConfigurationError({ message }) - } +function assertNotPromiseLike( + value: T, + code: PanicCode, + message?: string +): asserts value is NonPromise { + invariant( + !checkIsPromiseLike(value), + new Panic(code, message === undefined ? undefined : { message }) + ) } function checkIsSyncSafeRetryPolicy(retryPolicy: RetryPolicy | undefined): boolean { @@ -63,7 +68,7 @@ class RunSyncExecution extends BaseExecution extends BaseExecution extends BaseExecution extends BaseExecution extends BaseExecution 0) { - throw new ConfigurationError({ - message: "This retry policy may run asynchronously. Use run() instead.", - }) + throw new Panic("RUN_SYNC_ASYNC_RETRY_POLICY") } currentAttempt += 1 @@ -163,7 +166,7 @@ class RunSyncExecution extends BaseExecution( config: BuilderConfig, input: SyncRunInput ): T | E | RunnerError { - if (!checkIsSyncSafeRetryPolicy(config.retry)) { - throw new ConfigurationError({ - message: "This retry policy may run asynchronously. Use run() instead.", - }) - } + invariant(checkIsSyncSafeRetryPolicy(config.retry), new Panic("RUN_SYNC_ASYNC_RETRY_POLICY")) using execution = new RunSyncExecution(config, input) return execution.execute() @@ -201,23 +200,27 @@ export function runSync(input: RunSyncInput): T | E | UnhandledExcep try { const result = tryFn() - throwIfPromiseLike(result, "runSync() cannot handle Promise values. Use run() instead.") + assertNotPromiseLike(result, "RUN_SYNC_TRY_PROMISE") return result } catch (error) { - if (error instanceof Panic || error instanceof ConfigurationError) { + if (error instanceof Panic) { throw error } if (!catchFn) { - return new UnhandledException({ cause: error }) + return new UnhandledException(undefined, { cause: error }) } try { const mapped = catchFn(error) - throwIfPromiseLike(mapped, "runSync() catch cannot return a Promise. Use run() instead.") + assertNotPromiseLike(mapped, "RUN_SYNC_CATCH_PROMISE") return mapped } catch (catchError) { - throw new Panic({ cause: catchError }) + if (catchError instanceof Panic && catchError.code === "RUN_SYNC_CATCH_PROMISE") { + throw catchError + } + + throw new Panic("RUN_SYNC_CATCH_HANDLER_THROW", { cause: catchError }) } } } diff --git a/src/lib/executors/run.ts b/src/lib/executors/run.ts index 9f9aebb..cc078ef 100644 --- a/src/lib/executors/run.ts +++ b/src/lib/executors/run.ts @@ -37,19 +37,15 @@ export class RunExecution extends BaseExecution< try { mapped = this.#catchFn(error) } catch (catchError) { - throw new Panic({ cause: catchError }) + throw new Panic("RUN_CATCH_HANDLER_THROW", { cause: catchError }) } if (checkIsPromiseLike(mapped)) { - const mappedWithPanic = (async (): Promise => { - try { - return await mapped - } catch (catchError) { - throw new Panic({ cause: catchError }) - } - })() - - return await this.race(mappedWithPanic, error) + try { + return await this.race(mapped, error) + } catch (catchError) { + throw new Panic("RUN_CATCH_HANDLER_REJECT", { cause: catchError }) + } } const controlError = this.checkDidControlFail(error) @@ -67,7 +63,7 @@ export class RunExecution extends BaseExecution< return controlError } - return new UnhandledException({ cause: error }) + return new UnhandledException(undefined, { cause: error }) } /** Resolve an attempt error into either a terminal result or a retry decision. */ @@ -86,7 +82,7 @@ export class RunExecution extends BaseExecution< if (!retryDecision.shouldAttemptRetry) { if (retryDecision.isRetryExhausted) { - return new RetryExhaustedError({ cause: error }) + return new RetryExhaustedError(undefined, { cause: error }) } return await this.#finalizeFailure(error) @@ -126,7 +122,7 @@ export class RunExecution extends BaseExecution< if (checkIsPromiseLike(result)) { // oxlint-disable-next-line no-await-in-loop const raced = await this.race(Promise.resolve(result)) - return RunExecution.resolveRacedResult(raced) + return raced } return this.resolveSyncSuccess(result) diff --git a/src/lib/executors/shared.ts b/src/lib/executors/shared.ts index 7d86dde..b55d939 100644 --- a/src/lib/executors/shared.ts +++ b/src/lib/executors/shared.ts @@ -1,5 +1,6 @@ import type { ResultProxy, TaskContext, TaskRecord } from "../types/all" -import { UnhandledException } from "../errors" +import { Panic, UnhandledException } from "../errors" +import { invariant } from "../utils" type ResolverPair = [(value: unknown) => void, (reason?: unknown) => void] @@ -57,11 +58,19 @@ export class TaskExecution { #waitForResult(taskName: keyof T, requesterTaskName?: keyof T): Promise { if (requesterTaskName === taskName) { - return Promise.reject(new Error(`Task "${String(taskName)}" cannot await its own result`)) + return Promise.reject( + new Panic("TASK_SELF_REFERENCE", { + message: `Task "${String(taskName)}" cannot await its own result`, + }) + ) } if (!Object.hasOwn(this.#tasks, taskName)) { - return Promise.reject(new Error(`Unknown task "${String(taskName)}"`)) + return Promise.reject( + new Panic("TASK_UNKNOWN_REFERENCE", { + message: `Unknown task "${String(taskName)}"`, + }) + ) } if (this.#results.has(taskName)) { @@ -72,7 +81,9 @@ export class TaskExecution { const resultError = this.#errors.get(taskName) return Promise.reject( - resultError instanceof Error ? resultError : new UnhandledException({ cause: resultError }) + resultError instanceof Error + ? resultError + : new UnhandledException(undefined, { cause: resultError }) ) } @@ -132,9 +143,12 @@ export class TaskExecution { try { const taskFn = this.#tasks[taskName] - if (typeof taskFn !== "function") { - throw new Error(`Task "${String(taskName)}" is not a function`) - } + invariant( + typeof taskFn === "function", + new Panic("TASK_INVALID_HANDLER", { + message: `Task "${String(taskName)}" is not a function`, + }) + ) const resultProxy = new Proxy({} as ResultProxy, { get: (_, referencedTaskName: string) => diff --git a/src/lib/modifiers/__tests__/retry.test.ts b/src/lib/modifiers/__tests__/retry.test.ts index c3169ae..0f25e71 100644 --- a/src/lib/modifiers/__tests__/retry.test.ts +++ b/src/lib/modifiers/__tests__/retry.test.ts @@ -2,11 +2,10 @@ import { describe, expect, it } from "bun:test" import type { BuilderConfig } from "../../types/builder" import type { TryCtx } from "../../types/core" import { + createRetryPolicy, calculateRetryDelay, checkIsRetryExhausted, checkShouldAttemptRetry, - normalizeRetryPolicy, - retryOptions, } from "../retry" function createTestCtx(attempt: number, config: BuilderConfig): TryCtx { @@ -21,9 +20,9 @@ function createTestCtx(attempt: number, config: BuilderConfig): TryCtx { const shouldRetry = () => true -describe("normalizeRetryPolicy", () => { +describe("createRetryPolicy", () => { it("normalizes number shorthand to constant backoff", () => { - expect(normalizeRetryPolicy(3)).toEqual({ + expect(createRetryPolicy(3)).toEqual({ backoff: "constant", delayMs: 0, limit: 3, @@ -31,7 +30,7 @@ describe("normalizeRetryPolicy", () => { }) it("normalizes linear policy with default delay", () => { - expect(normalizeRetryPolicy({ backoff: "linear", limit: 2 })).toEqual({ + expect(createRetryPolicy({ backoff: "linear", limit: 2 })).toEqual({ backoff: "linear", delayMs: 0, jitter: undefined, @@ -42,7 +41,7 @@ describe("normalizeRetryPolicy", () => { it("normalizes exponential policy and preserves maxDelayMs", () => { expect( - normalizeRetryPolicy({ + createRetryPolicy({ backoff: "exponential", limit: 4, maxDelayMs: 1000, @@ -59,7 +58,7 @@ describe("normalizeRetryPolicy", () => { it("preserves optional retry controls", () => { expect( - normalizeRetryPolicy({ + createRetryPolicy({ backoff: "constant", delayMs: 25, jitter: true, @@ -74,11 +73,9 @@ describe("normalizeRetryPolicy", () => { shouldRetry, }) }) -}) -describe("retryOptions", () => { - it("returns normalized retry policy", () => { - expect(retryOptions(2)).toEqual({ + it("returns normalized retry policy for root helper usage", () => { + expect(createRetryPolicy(2)).toEqual({ backoff: "constant", delayMs: 0, limit: 2, diff --git a/src/lib/modifiers/retry.ts b/src/lib/modifiers/retry.ts index 30697d1..695364e 100644 --- a/src/lib/modifiers/retry.ts +++ b/src/lib/modifiers/retry.ts @@ -3,7 +3,7 @@ import type { TryCtx } from "../types/core" import type { RetryOptions, RetryPolicy } from "../types/retry" import { assertUnreachable } from "../utils" -export function normalizeRetryPolicy(policy: RetryOptions): RetryPolicy { +export function createRetryPolicy(policy: RetryOptions): RetryPolicy { if (typeof policy === "number") { return { backoff: "constant", @@ -26,14 +26,10 @@ export function normalizeRetryPolicy(policy: RetryOptions): RetryPolicy { case "exponential": return { ...base, backoff: policy.backoff, maxDelayMs: policy.maxDelayMs } default: - return assertUnreachable(policy) + return assertUnreachable(policy, "UNREACHABLE_RETRY_POLICY_BACKOFF") } } -export function retryOptions(policy: RetryOptions): RetryPolicy { - return normalizeRetryPolicy(policy) -} - export function calculateRetryDelay(attempt: number, config: BuilderConfig): number { const policy = config.retry diff --git a/src/lib/modifiers/signal.ts b/src/lib/modifiers/signal.ts index 887fb24..41cebdb 100644 --- a/src/lib/modifiers/signal.ts +++ b/src/lib/modifiers/signal.ts @@ -10,10 +10,6 @@ export class SignalController { } } - #createCancellationError(cause?: unknown): CancellationError { - return new CancellationError({ cause: cause ?? this.signal?.reason }) - } - checkDidCancel(cause?: unknown): CancellationError | undefined { if (!this.signal?.aborted) { return undefined @@ -23,7 +19,7 @@ export class SignalController { return undefined } - return this.#createCancellationError(cause) + return new CancellationError(undefined, { cause: cause ?? this.signal.reason }) } async race(promise: PromiseLike, cause?: unknown): Promise { @@ -37,8 +33,10 @@ export class SignalController { return promise } - return await raceWithAbortSignal(this.signal, promise, () => - this.#createCancellationError(cause) + return await raceWithAbortSignal( + this.signal, + promise, + () => new CancellationError(undefined, { cause: cause ?? this.signal?.reason }) ) } diff --git a/src/lib/modifiers/timeout.ts b/src/lib/modifiers/timeout.ts index c621026..07f7acc 100644 --- a/src/lib/modifiers/timeout.ts +++ b/src/lib/modifiers/timeout.ts @@ -10,10 +10,6 @@ export function normalizeTimeoutOptions(options: TimeoutOptions): TimeoutPolicy return { ...options } } -function createTimeoutCause(timeoutMs: number): Error { - return new Error(`Execution exceeded timeout of ${timeoutMs}ms`) -} - export class TimeoutController { readonly signal?: AbortSignal readonly #controller?: AbortController @@ -41,12 +37,11 @@ export class TimeoutController { }, this.#timeoutMs) } - #createTimeoutError(cause?: unknown): TimeoutError { - return new TimeoutError({ cause: cause ?? createTimeoutCause(this.#timeoutMs) }) - } - #abort(cause?: unknown): TimeoutError { - const timeoutError = this.#createTimeoutError(cause) + const timeoutError = + cause === undefined + ? new TimeoutError(`Execution exceeded timeout of ${this.#timeoutMs}ms`) + : new TimeoutError(`Execution exceeded timeout of ${this.#timeoutMs}ms`, { cause }) if (!this.signal?.aborted) { this.#controller?.abort(timeoutError) @@ -73,7 +68,11 @@ export class TimeoutController { return reason } - return this.#createTimeoutError(cause ?? reason) + return cause === undefined && reason === undefined + ? new TimeoutError(`Execution exceeded timeout of ${this.#timeoutMs}ms`) + : new TimeoutError(`Execution exceeded timeout of ${this.#timeoutMs}ms`, { + cause: cause ?? reason, + }) } const remaining = this.#getRemaining() @@ -105,7 +104,11 @@ export class TimeoutController { return await raceWithAbortSignal( this.signal, promise, - () => this.checkDidTimeout(cause) ?? this.#createTimeoutError(cause) + () => + this.checkDidTimeout(cause) ?? + (cause === undefined + ? new TimeoutError(`Execution exceeded timeout of ${this.#timeoutMs}ms`) + : new TimeoutError(`Execution exceeded timeout of ${this.#timeoutMs}ms`, { cause })) ) } diff --git a/src/lib/types/builder.ts b/src/lib/types/builder.ts index 2388fca..78ec713 100644 --- a/src/lib/types/builder.ts +++ b/src/lib/types/builder.ts @@ -28,14 +28,6 @@ export type TimeoutOptions = number | TimeoutPolicy */ export type WrapFn = (ctx: TryCtx, next: RunTryFn) => unknown -export const BuilderErrors = { - GEN_UNAVAILABLE: - "gen() is unavailable after retry(), timeout(), or signal(). Start a new builder chain or use top-level gen().", - RUN_SYNC_UNAVAILABLE: - "runSync() is unavailable after retry(), timeout(), or signal(). Use run() or start a new builder chain.", - WRAP_UNAVAILABLE: "wrap() is unavailable after retry(), timeout(), or signal().", -} as const - export interface BuilderConfig { /** * Retry configuration applied to the run. diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d0058b9..d415ad7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,7 +1,13 @@ -import { CancellationError, Panic, TimeoutError } from "./errors" +import { CancellationError, Panic, TimeoutError, type PanicCode } from "./errors" -export function assertUnreachable(value: never): never { - throw new Panic({ message: `Unreachable case: ${String(value)}` }) +export function assertUnreachable(value: never, code: PanicCode): never { + throw new Panic(code, { message: `Unreachable case: ${String(value)}` }) +} + +export function invariant(condition: unknown, error: Error): asserts condition { + if (!condition) { + throw error + } } export function sleep(ms: number): Promise {