Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,8 @@ disposer.defer(() => {
- `flow`
- `dispose`
- `gen`
- `retryOptions`
- `createRetryPolicy`
- `CancellationError`
- `ConfigurationError`
- `TimeoutError`
- `RetryExhaustedError`
- `UnhandledException`
Expand All @@ -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

Expand Down
62 changes: 44 additions & 18 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
}
})
})

Expand All @@ -65,7 +75,7 @@ describe("runSync", () => {
})

it("throws Panic when catch throws", () => {
expect(() =>
try {
try$.runSync({
catch: () => {
throw new Error("catch failed")
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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")
}
})

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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<typeof try$.wrap>[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 () => {
Expand Down Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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")
}
})

Expand Down Expand Up @@ -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")
}
})

Expand All @@ -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")
}
})
})
Expand Down Expand Up @@ -875,7 +901,7 @@ describe("all", () => {
)
expect.unreachable("should have thrown")
} catch (error) {
expect(error).toBeInstanceOf(try$.Panic)
expectPanic(error, "ALL_CATCH_HANDLER_THROW")
}
})

Expand All @@ -896,7 +922,7 @@ describe("all", () => {
)
expect.unreachable("should have thrown")
} catch (error) {
expect(error).toBeInstanceOf(try$.Panic)
expectPanic(error, "ALL_CATCH_HANDLER_REJECT")
}
})

Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Equal<typeof panic.code, try$.PanicCode>>
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)
Expand Down
15 changes: 4 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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,
Expand Down
32 changes: 29 additions & 3 deletions src/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ 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", () => {
expect(checkIsControlError(new CancellationError())).toBe(true)
})

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", () => {
Expand Down Expand Up @@ -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")
})
})
Loading