tryharder 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +336 -99
- package/dist/errors.js +1 -1
- package/dist/index.js +25 -13
- package/dist/shared/{chunk-996eswzx.js → chunk-vqm4xgxv.js} +2 -2
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
`tryharder` is a small execution layer for TypeScript. It keeps plain functions, object-shaped task definitions, and familiar control flow, but makes failure and execution policy explicit in the API surface.
|
|
15
|
+
|
|
16
|
+
Use it when `try/catch` starts absorbing too many concerns at once: retries, deadlines, cancellation, failure mapping, and orchestration. Instead of letting those concerns stay hidden in ambient `throw` paths, `tryharder` returns them as typed values and composes them around a single operation or a task graph.
|
|
17
|
+
|
|
18
|
+
It is deliberately narrower than a full effect runtime. You keep writing normal functions, but the return type tells you which values, mapped failures, and policy-level failures can come back from execution.
|
|
15
19
|
|
|
16
20
|
```ts
|
|
17
21
|
import * as try$ from "tryharder"
|
|
@@ -19,32 +23,35 @@ import * as try$ from "tryharder"
|
|
|
19
23
|
class RequestFailedError extends Error {}
|
|
20
24
|
|
|
21
25
|
const result = await try$
|
|
22
|
-
.retry(3) // Retry up to 3 times
|
|
23
|
-
.timeout(5_000) //
|
|
26
|
+
.retry(3) // Retry up to 3 times total, including the first attempt
|
|
27
|
+
.timeout(5_000) // Enforce one total deadline across attempts
|
|
24
28
|
.run({
|
|
25
29
|
try: async () => {
|
|
26
|
-
const
|
|
30
|
+
const order = await db.orders.findById("ord_123")
|
|
27
31
|
|
|
28
|
-
if (
|
|
29
|
-
throw new Error(
|
|
32
|
+
if (order === null) {
|
|
33
|
+
throw new Error("order not found")
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
return
|
|
36
|
+
return order.status
|
|
33
37
|
},
|
|
34
38
|
catch: () => new RequestFailedError("request failed"),
|
|
35
39
|
})
|
|
36
40
|
|
|
37
|
-
// result is
|
|
41
|
+
// result is OrderStatus | RequestFailedError | RetryExhaustedError | TimeoutError
|
|
38
42
|
```
|
|
39
43
|
|
|
40
44
|
<details>
|
|
41
45
|
<summary>Table of Contents</summary>
|
|
42
46
|
|
|
47
|
+
- [Why not plain try/catch?](#why-not-plain-trycatch)
|
|
43
48
|
- [Features](#features)
|
|
44
49
|
- [Installation](#installation)
|
|
45
50
|
- [Migration from hardtry](#migration-from-hardtry)
|
|
46
|
-
- [
|
|
51
|
+
- [Execution Model](#execution-model)
|
|
52
|
+
- [Type Semantics](#type-semantics)
|
|
47
53
|
- [Quick Start](#quick-start)
|
|
54
|
+
- [Orchestration Semantics](#orchestration-semantics)
|
|
48
55
|
- [Usage](#usage)
|
|
49
56
|
- [run and runSync](#run-and-runsync)
|
|
50
57
|
- [retry, timeout, signal](#retry-timeout-signal)
|
|
@@ -55,6 +62,7 @@ const result = await try$
|
|
|
55
62
|
- [dispose](#dispose)
|
|
56
63
|
- [API Reference](#api-reference)
|
|
57
64
|
- [Common Recipes](#common-recipes)
|
|
65
|
+
- [Limitations](#limitations)
|
|
58
66
|
- [When not to use tryharder](#when-not-to-use-tryharder)
|
|
59
67
|
- [Contributing](#contributing)
|
|
60
68
|
- [Acknowledgments](#acknowledgments)
|
|
@@ -62,16 +70,89 @@ const result = await try$
|
|
|
62
70
|
|
|
63
71
|
</details>
|
|
64
72
|
|
|
73
|
+
## Why not plain try/catch?
|
|
74
|
+
|
|
75
|
+
Plain `try/catch` works well for isolated code, but it scales poorly when one block starts carrying retry loops, cancellation wiring, timeout tracking, and domain error mapping at the same time.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
class UserUnavailableError extends Error {}
|
|
79
|
+
|
|
80
|
+
async function loadUser(signal: AbortSignal) {
|
|
81
|
+
let lastError: unknown
|
|
82
|
+
|
|
83
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
84
|
+
const timeout = AbortSignal.timeout(1_500)
|
|
85
|
+
const combined = AbortSignal.any([signal, timeout])
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const user = await db.users.findById("user_123", {
|
|
89
|
+
signal: combined,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (user === null) {
|
|
93
|
+
throw new Error("user not found")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return user
|
|
97
|
+
} catch (error) {
|
|
98
|
+
lastError = error
|
|
99
|
+
|
|
100
|
+
if (combined.aborted || attempt === 3) {
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return new UserUnavailableError("user service unavailable", {
|
|
107
|
+
cause: lastError,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
With `tryharder`, the execution policy is declared outside the work and the failure shape becomes part of the returned type:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
class UserUnavailableError extends Error {}
|
|
116
|
+
|
|
117
|
+
const controller = new AbortController()
|
|
118
|
+
|
|
119
|
+
const result = await try$
|
|
120
|
+
.retry(3)
|
|
121
|
+
.timeout(1_500)
|
|
122
|
+
.signal(controller.signal)
|
|
123
|
+
.run({
|
|
124
|
+
try: async ({ signal }) => {
|
|
125
|
+
const user = await db.users.findById("user_123", { signal })
|
|
126
|
+
|
|
127
|
+
if (user === null) {
|
|
128
|
+
throw new Error("user not found")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return user
|
|
132
|
+
},
|
|
133
|
+
catch: () => new UserUnavailableError("user service unavailable"),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// result is
|
|
137
|
+
// User | UserUnavailableError | RetryExhaustedError | TimeoutError | CancellationError
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
That is the core shift:
|
|
141
|
+
|
|
142
|
+
- Plain `try/catch` hides control flow and failure policy inside implementation details.
|
|
143
|
+
- `tryharder` exposes execution policy in the builder chain and failure shape in the return type.
|
|
144
|
+
- `run(fn)` returns `T | UnhandledException`.
|
|
145
|
+
- `run({ try, catch })` returns `T | C`.
|
|
146
|
+
- Adding `retry(...)`, `timeout(...)`, and `signal(...)` widens the union with `RetryExhaustedError`, `TimeoutError`, and `CancellationError`.
|
|
147
|
+
|
|
65
148
|
## Features
|
|
66
149
|
|
|
67
|
-
- **
|
|
68
|
-
- **
|
|
69
|
-
- **Sync and async
|
|
70
|
-
- **
|
|
71
|
-
- **
|
|
72
|
-
- **
|
|
73
|
-
- **Generator composition** - Build linear workflows over `run(...)` results with `gen(...)`.
|
|
74
|
-
- **Resource cleanup** - Register cleanup with `dispose()` and `AsyncDisposableStack`.
|
|
150
|
+
- **Explicit failure unions** - Model thrown failures as values in the returned type instead of an invisible side channel.
|
|
151
|
+
- **Execution policies** - Add retries, total deadlines, and cancellation around a unit of work without rewriting the work itself.
|
|
152
|
+
- **Sync and async parity** - Use the same mental model for `runSync(...)` and `run(...)`.
|
|
153
|
+
- **Named task orchestration** - Express concurrent and ordered workflows with object-shaped task graphs instead of positional arrays.
|
|
154
|
+
- **Observable execution hooks** - Add top-level instrumentation with `wrap(...)` without changing task behavior.
|
|
155
|
+
- **Resource cleanup** - Register teardown that survives async boundaries with `dispose()` and task disposers.
|
|
75
156
|
- **No runtime dependencies** - The published package ships without runtime dependencies.
|
|
76
157
|
|
|
77
158
|
## Installation
|
|
@@ -106,26 +187,69 @@ import * as try$ from "tryharder"
|
|
|
106
187
|
|
|
107
188
|
No runtime API names changed.
|
|
108
189
|
|
|
109
|
-
##
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
|
122
|
-
|
|
|
190
|
+
## Execution Model
|
|
191
|
+
|
|
192
|
+
`tryharder` has three layers: terminal execution APIs, policy builders, and orchestration APIs.
|
|
193
|
+
|
|
194
|
+
Terminal execution APIs are `run(...)` and `runSync(...)`. They are the points where work is actually executed and a result union is produced. Function form is the minimal shape and returns `T | UnhandledException`. Object form adds a `catch` mapper and returns `T | C`.
|
|
195
|
+
|
|
196
|
+
Policy builders decorate terminal execution. `retry(...)`, `timeout(...)`, and `signal(...)` do not run work by themselves; they configure the next terminal call and widen the resulting union with the policy-level failures they can introduce. `retry(limit)` counts the first attempt. `timeout(ms)` applies one total deadline across attempts, delays, and catch handling. `signal(abortSignal)` forwards external cancellation into execution.
|
|
197
|
+
|
|
198
|
+
Orchestration APIs scale the same model from one operation to a task graph. `all(...)` runs a fail-fast named task map. `allSettled(...)` preserves every settled task outcome. `flow(...)` runs an ordered workflow that must explicitly terminate through `this.$exit(...)`.
|
|
199
|
+
|
|
200
|
+
`wrap(...)` sits above those execution APIs as observational middleware. It can inspect readonly execution context and surround terminal calls, but it is not available after `retry(...)`, `timeout(...)`, or execution-scoped `signal(...)` chains. `gen(...)` offers a more linear way to compose returned unions. `dispose()` provides cleanup registration for work that spans async boundaries.
|
|
201
|
+
|
|
202
|
+
| Term | Meaning |
|
|
203
|
+
| --------------------- | ------------------------------------------------------------------------------ |
|
|
204
|
+
| `run` | Async terminal execution that returns a value, mapped failure, or policy error |
|
|
205
|
+
| `runSync` | Sync terminal execution for synchronous work only |
|
|
206
|
+
| `retry(limit)` | Retry policy where `limit` includes the first attempt |
|
|
207
|
+
| `timeout(ms)` | Total execution timeout across attempts, delays, and catch handling |
|
|
208
|
+
| `signal(abortSignal)` | External cancellation for `run(...)` and root-level orchestration |
|
|
209
|
+
| `wrap(fn)` | Top-level observational middleware around terminal APIs |
|
|
210
|
+
| `all(tasks)` | Fail-fast parallel named task graph |
|
|
211
|
+
| `allSettled(tasks)` | Settled parallel named task graph |
|
|
212
|
+
| `flow(tasks)` | Ordered task workflow with explicit early exit |
|
|
213
|
+
| `$exit(value)` | Stop a `flow(...)` early and return `value` |
|
|
123
214
|
|
|
124
215
|
Not sure if `tryharder` is a good fit for your project? See [When not to use tryharder](#when-not-to-use-tryharder).
|
|
125
216
|
|
|
217
|
+
## Type Semantics
|
|
218
|
+
|
|
219
|
+
`tryharder` treats failure as part of the return type. The important distinction is not just that failures are represented as values, but that builder chains preserve which layer introduced them.
|
|
220
|
+
|
|
221
|
+
- Domain failures are the values you map yourself with object-form `run({ try, catch })`.
|
|
222
|
+
- Runtime policy failures are introduced by `retry(...)`, `timeout(...)`, and `signal(...)`.
|
|
223
|
+
- Programmer misuse is represented by `Panic`, which is thrown for invalid API usage and invariant violations rather than returned as a domain result.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import * as try$ from "tryharder"
|
|
227
|
+
|
|
228
|
+
class ValidationError extends Error {}
|
|
229
|
+
|
|
230
|
+
const result = await try$
|
|
231
|
+
.retry(2)
|
|
232
|
+
.timeout(250)
|
|
233
|
+
.run({
|
|
234
|
+
try: async () => {
|
|
235
|
+
throw new Error("boom")
|
|
236
|
+
},
|
|
237
|
+
catch: () => new ValidationError("invalid input"),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// result is
|
|
241
|
+
// ValidationError | RetryExhaustedError | TimeoutError
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
That inferred union is the contract. A caller can see whether a function returns a domain error, whether retries may exhaust, and whether a deadline may fire, without reading the implementation body.
|
|
245
|
+
|
|
246
|
+
`Panic` is intentionally separate from that model. It signals programmer errors such as invalid builder usage or invalid task graphs, not expected business-domain failures.
|
|
247
|
+
|
|
248
|
+
One implementation detail worth knowing: `retry(...)` and `timeout(...)` switch the builder onto an execution-only surface. At both the type level and runtime, orchestration methods such as `all(...)`, `allSettled(...)`, `flow(...)`, and `wrap(...)` are not available from those execution-scoped builders. Root-level `signal(...)` still supports orchestration.
|
|
249
|
+
|
|
126
250
|
## Quick Start
|
|
127
251
|
|
|
128
|
-
Use function form when
|
|
252
|
+
Use function form when thrown failures should be preserved as `UnhandledException` values:
|
|
129
253
|
|
|
130
254
|
```ts
|
|
131
255
|
import * as try$ from "tryharder"
|
|
@@ -154,7 +278,7 @@ const result = await try$.run({
|
|
|
154
278
|
// ValidationError
|
|
155
279
|
```
|
|
156
280
|
|
|
157
|
-
In
|
|
281
|
+
In practice, you usually declare policy first and execute last:
|
|
158
282
|
|
|
159
283
|
```ts
|
|
160
284
|
class UpstreamUnavailableError extends Error {}
|
|
@@ -164,23 +288,79 @@ const result = await try$
|
|
|
164
288
|
.timeout(1_500)
|
|
165
289
|
.run({
|
|
166
290
|
try: async () => {
|
|
167
|
-
const
|
|
291
|
+
const account = await db.accounts.findById("acct_123")
|
|
168
292
|
|
|
169
|
-
if (
|
|
170
|
-
throw new Error("
|
|
293
|
+
if (account === null) {
|
|
294
|
+
throw new Error("account missing")
|
|
171
295
|
}
|
|
172
296
|
|
|
173
|
-
return
|
|
297
|
+
return account
|
|
174
298
|
},
|
|
175
|
-
catch: () => new UpstreamUnavailableError("
|
|
299
|
+
catch: () => new UpstreamUnavailableError("account store unavailable"),
|
|
176
300
|
})
|
|
177
301
|
```
|
|
178
302
|
|
|
303
|
+
## Orchestration Semantics
|
|
304
|
+
|
|
305
|
+
Use `run(...)` and `runSync(...)` for a single unit of work. Use `all(...)` or `allSettled(...)` when you want a concurrent task map with named dependencies. Use `flow(...)` when you need a stepwise workflow with explicit early return.
|
|
306
|
+
|
|
307
|
+
`all(...)` runs an object-shaped task graph and resolves to one object of successful results. Named tasks are easier to scan than positional arrays, and tasks can await earlier task results through `this.$result`. Execution is fail-fast: once one task fails, sibling task signals are aborted and the orchestration rejects unless you provide an orchestration-level `catch`.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
const result = await try$.all({
|
|
311
|
+
user() {
|
|
312
|
+
return { id: "1", name: "Ada" }
|
|
313
|
+
},
|
|
314
|
+
async profile() {
|
|
315
|
+
const user = await this.$result.user
|
|
316
|
+
return { userId: user.id, plan: "pro" as const }
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`allSettled(...)` uses the same task-graph shape, but preserves every task outcome as settled data. Use it when failure is expected input to the next decision rather than something that should short-circuit the whole graph.
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
const settled = await try$.allSettled({
|
|
325
|
+
fail() {
|
|
326
|
+
throw new Error("boom")
|
|
327
|
+
},
|
|
328
|
+
ok() {
|
|
329
|
+
return 1
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`flow(...)` is for dependent business-process style workflows. Tasks still read through `this.$result`, but completion is explicit: at least one path must call `this.$exit(...)`. That makes early return a visible part of the workflow contract instead of an implicit convention.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const result = await try$.flow({
|
|
338
|
+
cache() {
|
|
339
|
+
const cached: string | null = "cached-value"
|
|
340
|
+
|
|
341
|
+
if (cached !== null) {
|
|
342
|
+
return this.$exit(cached)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return null
|
|
346
|
+
},
|
|
347
|
+
async api() {
|
|
348
|
+
return "api-value"
|
|
349
|
+
},
|
|
350
|
+
async transform() {
|
|
351
|
+
const value = await this.$result.api
|
|
352
|
+
return this.$exit(`${value}-transformed`)
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
179
357
|
## Usage
|
|
180
358
|
|
|
181
359
|
### run and runSync
|
|
182
360
|
|
|
183
|
-
Use
|
|
361
|
+
Use `run(...)` and `runSync(...)` for leaf operations where you want execution and failure semantics attached directly to one function call.
|
|
362
|
+
|
|
363
|
+
Use function form when `UnhandledException` is an acceptable failure value:
|
|
184
364
|
|
|
185
365
|
```ts
|
|
186
366
|
const syncValue = try$.runSync(() => 42)
|
|
@@ -190,7 +370,7 @@ const asyncValue = await try$.run(async () => {
|
|
|
190
370
|
})
|
|
191
371
|
```
|
|
192
372
|
|
|
193
|
-
Use object form when you want to map failures yourself
|
|
373
|
+
Use object form when you want to map failures into domain results yourself:
|
|
194
374
|
|
|
195
375
|
```ts
|
|
196
376
|
class InvalidInputError extends Error {}
|
|
@@ -212,7 +392,7 @@ const result = try$.runSync({
|
|
|
212
392
|
|
|
213
393
|
### retry, timeout, signal
|
|
214
394
|
|
|
215
|
-
Use
|
|
395
|
+
Use these when execution policy belongs around a single unit of work. They decorate `run(...)` or `runSync(...)`, widen the returned union, and keep policy separate from business logic.
|
|
216
396
|
|
|
217
397
|
```ts
|
|
218
398
|
const controller = new AbortController()
|
|
@@ -228,9 +408,11 @@ const result = await try$
|
|
|
228
408
|
|
|
229
409
|
`timeout(ms)` measures total execution time, not just a single attempt.
|
|
230
410
|
|
|
411
|
+
Apply `signal(...)` on the root builder when you want cancellation to cover `all(...)`, `allSettled(...)`, or `flow(...)`.
|
|
412
|
+
|
|
231
413
|
### wrap
|
|
232
414
|
|
|
233
|
-
Use `wrap(...)` for
|
|
415
|
+
Use `wrap(...)` for logging, tracing, metrics, or other instrumentation that should observe execution without mutating it.
|
|
234
416
|
|
|
235
417
|
```ts
|
|
236
418
|
const result = await try$
|
|
@@ -242,11 +424,11 @@ const result = await try$
|
|
|
242
424
|
.run(async () => "ok")
|
|
243
425
|
```
|
|
244
426
|
|
|
245
|
-
`wrap(...)` is not available after `retry(...)`, `timeout(...)`, or `signal(...)`.
|
|
427
|
+
`wrap(...)` is top-level only and can be chained as `.wrap().wrap()`. It is not available after `retry(...)`, `timeout(...)`, or execution-scoped `signal(...)`.
|
|
246
428
|
|
|
247
429
|
### all and allSettled
|
|
248
430
|
|
|
249
|
-
Use `all(...)`
|
|
431
|
+
Use `all(...)` and `allSettled(...)` for concurrent work where named tasks and dependency reads are clearer than positional concurrency helpers.
|
|
250
432
|
|
|
251
433
|
```ts
|
|
252
434
|
const values = await try$.all({
|
|
@@ -273,9 +455,11 @@ const settled = await try$.allSettled({
|
|
|
273
455
|
})
|
|
274
456
|
```
|
|
275
457
|
|
|
458
|
+
Use `all(...)` when you want one successful combined value or one failure path. Use `allSettled(...)` when every outcome should be preserved for inspection.
|
|
459
|
+
|
|
276
460
|
### flow and $exit
|
|
277
461
|
|
|
278
|
-
Use `flow(...)` for
|
|
462
|
+
Use `flow(...)` for procedural workflows where steps depend on prior results and an explicit early return is part of the design.
|
|
279
463
|
|
|
280
464
|
```ts
|
|
281
465
|
const result = await try$.flow({
|
|
@@ -298,9 +482,11 @@ const result = await try$.flow({
|
|
|
298
482
|
})
|
|
299
483
|
```
|
|
300
484
|
|
|
485
|
+
At least one path must call `this.$exit(...)`. If no task exits, `flow(...)` throws `Panic`.
|
|
486
|
+
|
|
301
487
|
### gen
|
|
302
488
|
|
|
303
|
-
Use `gen(...)` when you want a more linear style
|
|
489
|
+
Use `gen(...)` when the returned unions are correct but nested handling becomes visually noisy and you want a more linear composition style.
|
|
304
490
|
|
|
305
491
|
```ts
|
|
306
492
|
const value = await try$.gen(function* (use) {
|
|
@@ -312,34 +498,37 @@ const value = await try$.gen(function* (use) {
|
|
|
312
498
|
|
|
313
499
|
### dispose
|
|
314
500
|
|
|
315
|
-
Use `dispose()`
|
|
501
|
+
Use `dispose()` when cleanup should stay colocated with the workflow that allocates the resource, even across async boundaries.
|
|
316
502
|
|
|
317
503
|
```ts
|
|
318
504
|
await using disposer = try$.dispose()
|
|
505
|
+
const connection = await db.connect()
|
|
319
506
|
|
|
320
|
-
disposer.defer(() => {
|
|
321
|
-
|
|
507
|
+
disposer.defer(async () => {
|
|
508
|
+
await connection.close()
|
|
322
509
|
})
|
|
510
|
+
|
|
511
|
+
const user = await connection.users.findById("user_123")
|
|
323
512
|
```
|
|
324
513
|
|
|
325
514
|
## API Reference
|
|
326
515
|
|
|
327
516
|
### Runtime
|
|
328
517
|
|
|
329
|
-
| Export | Description
|
|
330
|
-
| -------------- |
|
|
331
|
-
| `run` | Async execution
|
|
332
|
-
| `runSync` | Sync execution
|
|
333
|
-
| `retry` | Create
|
|
334
|
-
| `retryOptions` | Normalize retry policy input
|
|
335
|
-
| `timeout` | Add a total execution timeout
|
|
336
|
-
| `signal` | Add external cancellation
|
|
337
|
-
| `wrap` | Add top-level middleware
|
|
338
|
-
| `all` | Run fail-fast parallel named
|
|
339
|
-
| `allSettled` | Run settled parallel named
|
|
340
|
-
| `flow` | Run ordered
|
|
341
|
-
| `gen` | Compose `run(...)`
|
|
342
|
-
| `dispose` | Create an `AsyncDisposableStack` helper
|
|
518
|
+
| Export | Description |
|
|
519
|
+
| -------------- | ------------------------------------------------------------------ |
|
|
520
|
+
| `run` | Async terminal execution API |
|
|
521
|
+
| `runSync` | Sync terminal execution API |
|
|
522
|
+
| `retry` | Create an execution-scoped retry builder |
|
|
523
|
+
| `retryOptions` | Normalize retry policy input |
|
|
524
|
+
| `timeout` | Add a total execution timeout |
|
|
525
|
+
| `signal` | Add external cancellation to execution or root-level orchestration |
|
|
526
|
+
| `wrap` | Add top-level observational middleware |
|
|
527
|
+
| `all` | Run a fail-fast parallel named task graph |
|
|
528
|
+
| `allSettled` | Run a settled parallel named task graph |
|
|
529
|
+
| `flow` | Run an ordered workflow with explicit early exit |
|
|
530
|
+
| `gen` | Compose `run(...)` results through generators |
|
|
531
|
+
| `dispose` | Create an `AsyncDisposableStack` helper |
|
|
343
532
|
|
|
344
533
|
### Errors
|
|
345
534
|
|
|
@@ -373,75 +562,123 @@ import type { FlowExit, SettledResult } from "tryharder/types"
|
|
|
373
562
|
|
|
374
563
|
## Common Recipes
|
|
375
564
|
|
|
376
|
-
###
|
|
565
|
+
### Map infrastructure failure into domain error
|
|
566
|
+
|
|
567
|
+
Use object-form `run(...)` when transport or infrastructure failures should be normalized into a domain-level result.
|
|
377
568
|
|
|
378
569
|
```ts
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
570
|
+
class PaymentUnavailableError extends Error {}
|
|
571
|
+
|
|
572
|
+
const result = await try$.run({
|
|
573
|
+
try: async () => {
|
|
574
|
+
const payment = await db.payments.findById("pay_123")
|
|
575
|
+
|
|
576
|
+
if (payment === null) {
|
|
577
|
+
throw new Error("payment missing")
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return payment
|
|
581
|
+
},
|
|
582
|
+
catch: () => new PaymentUnavailableError("payments unavailable"),
|
|
583
|
+
})
|
|
386
584
|
```
|
|
387
585
|
|
|
388
|
-
###
|
|
586
|
+
### Retry only the leaf request inside a flow
|
|
587
|
+
|
|
588
|
+
`retry(...)` and `timeout(...)` do not apply directly to `flow(...)`. Wrap the leaf work in nested `run(...)` calls when a single step needs its own execution policy.
|
|
389
589
|
|
|
390
590
|
```ts
|
|
391
|
-
|
|
591
|
+
const result = await try$.flow({
|
|
592
|
+
async fetchUser() {
|
|
593
|
+
const user = await try$.retry(2).run(async () => {
|
|
594
|
+
const row = await db.users.findById("user_123")
|
|
392
595
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
596
|
+
if (row === null) {
|
|
597
|
+
throw new Error("user missing")
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return row
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
return this.$exit(user)
|
|
396
604
|
},
|
|
397
|
-
catch: () => new RemoteServiceError("service failed"),
|
|
398
605
|
})
|
|
399
606
|
```
|
|
400
607
|
|
|
401
|
-
###
|
|
608
|
+
### Choose all vs allSettled
|
|
609
|
+
|
|
610
|
+
Use `all(...)` when the workflow should stop on the first failure:
|
|
402
611
|
|
|
403
612
|
```ts
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
return {
|
|
613
|
+
const strict = await try$.all({
|
|
614
|
+
config() {
|
|
615
|
+
return { region: "us-east-1" as const }
|
|
407
616
|
},
|
|
408
|
-
async
|
|
409
|
-
const
|
|
410
|
-
return
|
|
617
|
+
async client() {
|
|
618
|
+
const config = await this.$result.config
|
|
619
|
+
return connect(config)
|
|
411
620
|
},
|
|
412
621
|
})
|
|
413
622
|
```
|
|
414
623
|
|
|
415
|
-
|
|
624
|
+
Use `allSettled(...)` when failure is data you want to inspect:
|
|
416
625
|
|
|
417
626
|
```ts
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
return
|
|
627
|
+
const observed = await try$.allSettled({
|
|
628
|
+
primary() {
|
|
629
|
+
return db.reports.readFromPrimary("daily-active-users")
|
|
421
630
|
},
|
|
422
|
-
|
|
423
|
-
return "
|
|
631
|
+
replica() {
|
|
632
|
+
return db.reports.readFromReplica("daily-active-users")
|
|
424
633
|
},
|
|
425
634
|
})
|
|
426
635
|
```
|
|
427
636
|
|
|
428
|
-
###
|
|
637
|
+
### Use signal at the root for orchestration cancellation
|
|
429
638
|
|
|
430
|
-
|
|
639
|
+
Root-level `signal(...)` propagates cancellation through orchestration APIs.
|
|
431
640
|
|
|
432
641
|
```ts
|
|
433
|
-
const
|
|
434
|
-
async fetchUser() {
|
|
435
|
-
const user = await try$.retry(2).run(async () => {
|
|
436
|
-
const response = await fetch("https://example.com/user")
|
|
437
|
-
return await response.json()
|
|
438
|
-
})
|
|
642
|
+
const controller = new AbortController()
|
|
439
643
|
|
|
440
|
-
|
|
644
|
+
const result = await try$.signal(controller.signal).all({
|
|
645
|
+
async a() {
|
|
646
|
+
return db.users.findById("user_123", { signal: this.$signal })
|
|
647
|
+
},
|
|
648
|
+
async b() {
|
|
649
|
+
return db.accounts.findById("acct_123", { signal: this.$signal })
|
|
441
650
|
},
|
|
442
651
|
})
|
|
443
652
|
```
|
|
444
653
|
|
|
654
|
+
### Choose object-form run vs function-form run
|
|
655
|
+
|
|
656
|
+
Use function-form `run(...)` when `UnhandledException` is an acceptable boundary type:
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
const value = await try$.run(async () => {
|
|
660
|
+
return JSON.parse('{"ok":true}')
|
|
661
|
+
})
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Use object-form `run(...)` when callers should receive domain-specific failures instead:
|
|
665
|
+
|
|
666
|
+
```ts
|
|
667
|
+
class InvalidPayloadError extends Error {}
|
|
668
|
+
|
|
669
|
+
const value = await try$.run({
|
|
670
|
+
try: () => JSON.parse("not-json"),
|
|
671
|
+
catch: () => new InvalidPayloadError("payload was invalid"),
|
|
672
|
+
})
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
## Limitations
|
|
676
|
+
|
|
677
|
+
- `tryharder` currently assumes `DisposableStack` and `AsyncDisposableStack` are available at runtime because the executor layer uses them internally, not just when you call `dispose()` yourself.
|
|
678
|
+
- This can break consumers on runtimes that do not yet provide those globals, notably Firefox and Safari.
|
|
679
|
+
- The tracked fix is [#36 Bundle DisposableStack polyfill for runtimes without native support](https://github.com/adelrodriguez/tryharder/issues/36).
|
|
680
|
+
- Until that issue is resolved, use `tryharder` on runtimes with native disposable-stack support or provide a compatible polyfill before loading the package.
|
|
681
|
+
|
|
445
682
|
## When not to use tryharder
|
|
446
683
|
|
|
447
684
|
- **Small scripts or one-off tasks** - Plain `try/catch` is often simpler when you do not need retries, cancellation, or orchestration.
|