@vereda/http 1.0.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/LICENSE +21 -0
- package/README.md +604 -0
- package/dist/adapters/zod.d.ts +14 -0
- package/dist/adapters/zod.d.ts.map +1 -0
- package/dist/adapters/zod.js +14 -0
- package/dist/adapters/zod.js.map +1 -0
- package/dist/core/backoff.d.ts +9 -0
- package/dist/core/backoff.d.ts.map +1 -0
- package/dist/core/backoff.js +23 -0
- package/dist/core/backoff.js.map +1 -0
- package/dist/core/client.d.ts +98 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +781 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/errors.d.ts +87 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +140 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +10 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/listeners.d.ts +14 -0
- package/dist/core/listeners.d.ts.map +1 -0
- package/dist/core/listeners.js +27 -0
- package/dist/core/listeners.js.map +1 -0
- package/dist/core/metrics.d.ts +33 -0
- package/dist/core/metrics.d.ts.map +1 -0
- package/dist/core/metrics.js +24 -0
- package/dist/core/metrics.js.map +1 -0
- package/dist/core/nanoid.d.ts +2 -0
- package/dist/core/nanoid.d.ts.map +1 -0
- package/dist/core/nanoid.js +11 -0
- package/dist/core/nanoid.js.map +1 -0
- package/dist/core/redact.d.ts +12 -0
- package/dist/core/redact.d.ts.map +1 -0
- package/dist/core/redact.js +42 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/types.d.ts +261 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +41 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/validate.d.ts +19 -0
- package/dist/core/validate.d.ts.map +1 -0
- package/dist/core/validate.js +135 -0
- package/dist/core/validate.js.map +1 -0
- package/dist/middleware/index.d.ts +26 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +55 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/queue/bulkhead.d.ts +63 -0
- package/dist/queue/bulkhead.d.ts.map +1 -0
- package/dist/queue/bulkhead.js +192 -0
- package/dist/queue/bulkhead.js.map +1 -0
- package/dist/queue/circuit-breaker.d.ts +81 -0
- package/dist/queue/circuit-breaker.d.ts.map +1 -0
- package/dist/queue/circuit-breaker.js +283 -0
- package/dist/queue/circuit-breaker.js.map +1 -0
- package/dist/queue/executor.d.ts +67 -0
- package/dist/queue/executor.d.ts.map +1 -0
- package/dist/queue/executor.js +273 -0
- package/dist/queue/executor.js.map +1 -0
- package/dist/queue/policy.d.ts +26 -0
- package/dist/queue/policy.d.ts.map +1 -0
- package/dist/queue/policy.js +37 -0
- package/dist/queue/policy.js.map +1 -0
- package/dist/queue/retry.d.ts +58 -0
- package/dist/queue/retry.d.ts.map +1 -0
- package/dist/queue/retry.js +259 -0
- package/dist/queue/retry.js.map +1 -0
- package/dist/queue/semaphore.d.ts +32 -0
- package/dist/queue/semaphore.d.ts.map +1 -0
- package/dist/queue/semaphore.js +83 -0
- package/dist/queue/semaphore.js.map +1 -0
- package/dist/ticket/ticket.d.ts +77 -0
- package/dist/ticket/ticket.d.ts.map +1 -0
- package/dist/ticket/ticket.js +186 -0
- package/dist/ticket/ticket.js.map +1 -0
- package/package.json +85 -0
- package/src/adapters/zod.ts +16 -0
- package/src/core/backoff.ts +26 -0
- package/src/core/client.ts +1048 -0
- package/src/core/errors.ts +194 -0
- package/src/core/index.ts +56 -0
- package/src/core/listeners.ts +28 -0
- package/src/core/metrics.ts +42 -0
- package/src/core/nanoid.ts +11 -0
- package/src/core/redact.ts +46 -0
- package/src/core/types.ts +306 -0
- package/src/core/validate.ts +163 -0
- package/src/middleware/index.ts +63 -0
- package/src/queue/bulkhead.ts +243 -0
- package/src/queue/circuit-breaker.ts +373 -0
- package/src/queue/executor.ts +355 -0
- package/src/queue/policy.ts +49 -0
- package/src/queue/retry.ts +380 -0
- package/src/queue/semaphore.ts +91 -0
- package/src/ticket/ticket.ts +246 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gabriel Rios
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="Vereda" width="200" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Vereda</h1>
|
|
6
|
+
|
|
7
|
+
<h3 align="center">Make <code>fetch</code> resilient.</h3>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
Retries, backoff, timeouts, per-host isolation, and circuit breaking for Node.js <code>fetch</code> — with typed results instead of thrown errors.
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<a href="https://github.com/riosgabriel/vereda/actions/workflows/ci.yml"><img src="https://github.com/riosgabriel/vereda/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
15
|
+
<a href="https://github.com/riosgabriel/vereda/blob/main/LICENSE"><img src="https://img.shields.io/github/license/riosgabriel/vereda" alt="License" /></a>
|
|
16
|
+
<a href="https://nodejs.org/en/about/previous-releases"><img src="https://img.shields.io/badge/node-22%2B-green" alt="Node 22+" /></a>
|
|
17
|
+
<a href="https://github.com/riosgabriel/vereda"><img src="https://img.shields.io/badge/ESM-only-blue" alt="ESM only" /></a>
|
|
18
|
+
<a href="https://github.com/riosgabriel/vereda"><img src="https://img.shields.io/badge/TypeScript-6.0-blue" alt="TypeScript" /></a>
|
|
19
|
+
<a href="https://github.com/riosgabriel/vereda/blob/main/package.json"><img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero runtime dependencies" /></a>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { HttpClient } from "@vereda/http";
|
|
24
|
+
|
|
25
|
+
const api = HttpClient.create({
|
|
26
|
+
baseUrl: "https://api.example.com",
|
|
27
|
+
timeout: { attemptMs: 5_000 },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = await api.get("/users/42").toPromise(); // never rejects
|
|
31
|
+
|
|
32
|
+
if (result.success) {
|
|
33
|
+
const user = await result.raw.json();
|
|
34
|
+
} else {
|
|
35
|
+
console.error(result.error.kind, result.error.message); // a typed error
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A dropped connection, a timeout, or a `503` on that request is retried up to three times with jittered exponential backoff before your code sees an error. Reading `result.raw` afterwards is bounded too: the body read has to finish within the same `attemptMs` (counted from when the attempt started) and `totalMs` limits, or the read rejects with Vereda's own `TimeoutError` (attempt bound) or `DeadlineExceededError` (`totalMs` bound).
|
|
40
|
+
|
|
41
|
+
## Why Vereda?
|
|
42
|
+
|
|
43
|
+
`fetch` makes one attempt. Everything after that is yours to write:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
async function getUser(id: string) {
|
|
47
|
+
for (let attempt = 0; ; attempt++) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`https://api.example.com/users/${id}`, {
|
|
50
|
+
signal: AbortSignal.timeout(5_000),
|
|
51
|
+
});
|
|
52
|
+
if (res.ok) return await res.json();
|
|
53
|
+
throw new Error(`HTTP ${res.status}`); // 404 or 503? retry both?
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// timeout, DNS failure, or the throw above? caught all the same
|
|
56
|
+
// is this request safe to repeat? what if it were a POST?
|
|
57
|
+
if (attempt === 3) throw err;
|
|
58
|
+
}
|
|
59
|
+
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt)); // jitter? Retry-After?
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Even once that loop is correct, it has no limit on how many retries pile onto a struggling host, no way to cancel, and callers that can only tell a `404` from a timeout by parsing an error message. Vereda is that loop written carefully, once:
|
|
65
|
+
|
|
66
|
+
| When… | Vereda… |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| A request fails transiently (connection reset, timeout, `408`, `425`, `429`, `500`, `502`–`504`) | retries it with exponential backoff and full jitter, honoring `Retry-After` |
|
|
69
|
+
| A retry could duplicate a side effect | retries only idempotent methods unless you opt in or send an `Idempotency-Key` |
|
|
70
|
+
| A request hangs | aborts each attempt at `timeout.attemptMs`; an optional `timeout.totalMs` caps the whole request |
|
|
71
|
+
| One failing host would soak up your retries | caps retry concurrency and queue size per host, so one host's retries can't fill another's queue (all hosts still share the global `concurrency` cap) |
|
|
72
|
+
| A host is down, not just slow | an opt-in circuit breaker fails fast with `CircuitOpenError` until it recovers |
|
|
73
|
+
| The response isn't the shape you expected | validates it with your `parse` function (or Zod); a failed parse is never retried |
|
|
74
|
+
| The caller no longer needs the answer | cancels via the ticket or your `AbortSignal`; a cancelled request is never retried |
|
|
75
|
+
| You need to know what happened | emits typed lifecycle events (with attempt counts and queue time) and [metrics](#metrics) (requests, retries, latency, in-flight, queue depth, breaker trips), tagged per partition |
|
|
76
|
+
| You need auth headers, logging, URL rewriting | runs onion middleware around every attempt |
|
|
77
|
+
|
|
78
|
+
**What Vereda does not do.** No response caching, no request deduplication, no streaming helpers, no browser support. It targets Node.js 22+ services that depend on other services; for a handful of calls in a script, plain `fetch` is fine.
|
|
79
|
+
|
|
80
|
+
## Quick start
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm install @vereda/http
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { HttpClient, json } from "@vereda/http";
|
|
88
|
+
|
|
89
|
+
type User = { id: number; name: string };
|
|
90
|
+
|
|
91
|
+
const api = HttpClient.create({
|
|
92
|
+
baseUrl: "https://api.example.com",
|
|
93
|
+
timeout: { attemptMs: 5_000 },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await api.get("/users/42", { parse: json<User>() }).toPromise();
|
|
97
|
+
|
|
98
|
+
if (result.success) {
|
|
99
|
+
result.data.name; // typed: string
|
|
100
|
+
} else {
|
|
101
|
+
switch (result.error.kind) {
|
|
102
|
+
case "http": // non-retryable status, e.g. 404
|
|
103
|
+
console.warn(result.error.statusCode);
|
|
104
|
+
break;
|
|
105
|
+
case "max_retries": // transient failures outlasted every retry
|
|
106
|
+
console.error(result.error.lastError);
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
console.error(result.error.message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`toPromise()` never rejects: every outcome is a `Result`, and every failure is one of a closed set of error classes, discriminated by `kind` ([Error handling](#error-handling)). `json<T>()` casts without checking; pass a real validator, or use the [Zod adapter](#zod-adapter-optional), when you need the shape enforced.
|
|
115
|
+
|
|
116
|
+
**`timeout.attemptMs` is the one required setting.** Most HTTP clients have no overall request timeout by default, which is how one hung dependency takes a service down. Vereda makes you choose a number, or pass `Infinity` to opt out on purpose. Everything else has a default:
|
|
117
|
+
|
|
118
|
+
| Setting | Default |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| Retries | 3 retries after the first attempt (4 total executions) |
|
|
121
|
+
| Backoff | Exponential: 200ms base, 30s cap, full jitter |
|
|
122
|
+
| Retry-on status codes | `[408, 425, 429, 500, 502, 503, 504]` |
|
|
123
|
+
| Per-partition concurrency | 5 retries in flight per host |
|
|
124
|
+
| Per-partition queue size | 100 waiting retries per host |
|
|
125
|
+
| Global concurrency | 50 in-flight executions across all partitions |
|
|
126
|
+
| Global queue size | 100 waiting executions; beyond that a request resolves with `QueueFullError` (`partition: "global"`) |
|
|
127
|
+
| First attempts | Skip the per-partition bulkhead (unless `partition.limitFirstAttempts` is set), but still take a global permit |
|
|
128
|
+
| Total deadline | None — set `timeout.totalMs` to cap the whole request |
|
|
129
|
+
| Circuit breaker | Disabled — opt in with `circuitBreaker: { enabled: true }` |
|
|
130
|
+
|
|
131
|
+
## Example: one failing dependency
|
|
132
|
+
|
|
133
|
+
A checkout service calls three hosts. The payment provider starts returning `503`.
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
import { HttpClient } from "@vereda/http";
|
|
137
|
+
|
|
138
|
+
const client = HttpClient.create({
|
|
139
|
+
timeout: { attemptMs: 3_000, totalMs: 15_000 },
|
|
140
|
+
partitions: {
|
|
141
|
+
"payments.example.com": {
|
|
142
|
+
concurrency: 2,
|
|
143
|
+
maxQueueSize: 20,
|
|
144
|
+
circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeoutMs: 30_000 },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// POST isn't retried by default; the idempotency key tells Vereda a repeat is safe.
|
|
150
|
+
const charge = client.post("https://payments.example.com/charges", JSON.stringify(order), {
|
|
151
|
+
headers: { "Content-Type": "application/json", "Idempotency-Key": order.id },
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
checkout service
|
|
157
|
+
│
|
|
158
|
+
├──► payments.example.com 503, 503, 503 …
|
|
159
|
+
│ retries: at most 2 in flight, 20 waiting, the rest fail fast with QueueFullError
|
|
160
|
+
│ after 5 straight failures: circuit opens, calls fail instantly with CircuitOpenError
|
|
161
|
+
│ after 30s: one trial request; success closes the circuit
|
|
162
|
+
│
|
|
163
|
+
├──► inventory.example.com own partition: its retries aren't queued behind payments'
|
|
164
|
+
└──► shipping.example.com own partition: same
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Every request is assigned to a partition by host, and each partition has its own retry queue and its own breaker. Payments' retries are capped at 2 in flight, and once its breaker opens, payment calls stop reaching the network, apart from the half-open trial and any retry that had already passed the breaker check before its backoff. The `totalMs` deadline means no single call waits longer than 15 seconds, retries included.
|
|
168
|
+
|
|
169
|
+
One limit is shared: every attempt, first attempts included, takes a permit from the client-wide `concurrency` cap (default 50, with 100 waiting). A host that fails *slowly* holds those permits while it hangs, so under enough load it can delay or reject requests to healthy hosts. Keep `attemptMs` short for dependencies that tend to hang, and set `limitFirstAttempts: true` on a partition to put its fresh traffic behind its own bulkhead too.
|
|
170
|
+
|
|
171
|
+
[`examples/checkout/`](examples/checkout/) is this scenario as a runnable app: stub upstreams on localhost, a small checkout server, and a driver that asserts the retries and the breaker tripping while inventory and shipping keep succeeding (sequential traffic and smaller numbers, so it runs fast; it doesn't exercise the queue limits). Clone the repo and run `npm run example:checkout`.
|
|
172
|
+
|
|
173
|
+
It ends by printing what a dashboard fed from Vereda's [metrics](#metrics) would show for that run. Every metric is tagged with its partition, so the failing dependency is easy to pick out:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
dependency requests retries p50 ms max ms circuit_open
|
|
177
|
+
inventory 5 0 3 8 0
|
|
178
|
+
payments 5 2 1 21 1
|
|
179
|
+
shipping 5 0 3 7 0
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Note payments' low median latency, despite the outage: once its breaker opened, its calls were rejected on the spot instead of waiting on a failing host.
|
|
183
|
+
|
|
184
|
+
## How it works
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
client.get(url)
|
|
188
|
+
│
|
|
189
|
+
▼
|
|
190
|
+
breaker check ─> global permit ─> first attempt
|
|
191
|
+
│
|
|
192
|
+
┌────────────────────────────────►┤ outcome of each attempt
|
|
193
|
+
│ ├─ success ─────────────────> done
|
|
194
|
+
│ ├─ non-retryable or vetoed ─> resolves with that error
|
|
195
|
+
│ ├─ transient, none left ────> MaxRetriesExceededError
|
|
196
|
+
│ └─ transient, retries left
|
|
197
|
+
│ │
|
|
198
|
+
│ breaker check ─> backoff ─> partition bulkhead ─> global permit ─> retry
|
|
199
|
+
│ (per host) │
|
|
200
|
+
└───────────────────────────────────────────────────────────────────────┘
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The first attempt skips the partition bulkhead. Only requests that need another attempt go through their partition's queue, so a host's retry backlog waits in its own partition queue, not ahead of other hosts' retries. Once admitted, a retry still needs a permit from the global `concurrency` cap, which it waits for alongside fresh requests. When the circuit breaker is enabled, it is checked before the first attempt and again before every retry.
|
|
204
|
+
|
|
205
|
+
## Features
|
|
206
|
+
|
|
207
|
+
### Retries and backoff
|
|
208
|
+
|
|
209
|
+
Configure retries globally or per request. Per-request settings override global ones.
|
|
210
|
+
|
|
211
|
+
#### What gets retried
|
|
212
|
+
|
|
213
|
+
By default, a failed attempt is retried only when the error is transient **and** the request is safe to repeat:
|
|
214
|
+
|
|
215
|
+
| Failure | `kind` | Retried by default |
|
|
216
|
+
| --- | --- | --- |
|
|
217
|
+
| Network failure | `network` | Yes — idempotent requests |
|
|
218
|
+
| Attempt timed out | `timeout` | Yes — idempotent requests |
|
|
219
|
+
| Busy status (`408, 425, 429, 500, 502, 503, 504`) | `retryable_status` | Yes — idempotent requests |
|
|
220
|
+
| Any other HTTP status (e.g. `404`) | `http` | No |
|
|
221
|
+
| Response failed `parse` | `validation` | Never |
|
|
222
|
+
| Cancelled | `cancelled` | Never |
|
|
223
|
+
| Partition or global queue full | `queue_full` | Never |
|
|
224
|
+
| Circuit open | `circuit_open` | Never |
|
|
225
|
+
| Invalid configuration | `configuration` | Never |
|
|
226
|
+
|
|
227
|
+
Idempotent means `GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`, or `TRACE`. Non-idempotent methods (`POST`, `PATCH`, `CONNECT`) are not retried, since blindly repeating them could duplicate a side effect; opt in with `retry: { idempotent: true }` or by sending an `Idempotency-Key` header. The busy-status list is `retry.retryOnStatus`, and the underlying `defaultRetryPolicy` is exported for inspection, or to call from inside `retryWhen`.
|
|
228
|
+
|
|
229
|
+
`maxRetries: 0` disables retries entirely — a failed request resolves with its own error, unwrapped. When retries run out and the last failure was still transient, the ticket resolves with a `MaxRetriesExceededError` carrying the attempt count and the last underlying error. If an attempt fails with a non-retryable error, that error is returned as is.
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import { HttpClient } from "@vereda/http";
|
|
233
|
+
|
|
234
|
+
const client = HttpClient.create({
|
|
235
|
+
timeout: { attemptMs: 5_000 },
|
|
236
|
+
retry: {
|
|
237
|
+
maxRetries: 5,
|
|
238
|
+
retryOnStatus: [429, 503],
|
|
239
|
+
backoff: {
|
|
240
|
+
baseDelayMs: 1000,
|
|
241
|
+
maxDelayMs: 30000,
|
|
242
|
+
jitter: true,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The default backoff is `200ms * 2^attempt`, capped at 30s, with full jitter applied. Jitter spreads retries out so a fleet of clients doesn't hit a recovering server at the same instant. Retries of a `retryOnStatus` response honor its `Retry-After` header (seconds or HTTP-date), capped at `backoff.maxDelayMs` (30s when `backoff` is a function) and without jitter; without one, the configured backoff drives the delay.
|
|
249
|
+
|
|
250
|
+
You can also supply a custom backoff function:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { HttpClient } from "@vereda/http";
|
|
254
|
+
|
|
255
|
+
const client = HttpClient.create({
|
|
256
|
+
timeout: { attemptMs: 5_000 },
|
|
257
|
+
retry: {
|
|
258
|
+
maxRetries: 3,
|
|
259
|
+
backoff: (attempt) => Math.min(100 * 2 ** attempt, 10000),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`retryWhen` is consulted once after every failed attempt that could still be retried, including the first one. It runs after the default policy and can only veto a retry, never force one. Return `false` to surface the error immediately:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { HttpClient, NetworkError } from "@vereda/http";
|
|
268
|
+
|
|
269
|
+
const client = HttpClient.create({
|
|
270
|
+
timeout: { attemptMs: 5_000 },
|
|
271
|
+
retry: {
|
|
272
|
+
maxRetries: 5,
|
|
273
|
+
retryWhen: (error, attempt) => {
|
|
274
|
+
if (error instanceof NetworkError) return false;
|
|
275
|
+
return true;
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
A request `body` may also be supplied as a factory (`() => BodyInit`); the factory is invoked fresh on every attempt so the payload can be replayed across retries. This is required when the body is a `ReadableStream` — passing a bare stream is a `ConfigurationError`. When a stream body is used, `duplex: "half"` is set on the fetch call automatically.
|
|
282
|
+
|
|
283
|
+
### Timeouts
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
import { HttpClient } from "@vereda/http";
|
|
287
|
+
|
|
288
|
+
const client = HttpClient.create({
|
|
289
|
+
timeout: {
|
|
290
|
+
attemptMs: 5_000,
|
|
291
|
+
totalMs: 20_000,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
client.get("/reports/slow", { timeout: { attemptMs: 15_000 } });
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- `attemptMs` — a hard per-attempt timeout. The attempt is aborted with a `TimeoutError`, which is retryable. Required on the client-level `timeout` config — pass `Infinity` to explicitly opt out of a cap. Partition- and request-level `timeout` stay optional and inherit the client default.
|
|
299
|
+
- `totalMs` — a deadline for the whole ticket, across every attempt and backoff delay. On expiry the in-flight attempt is aborted and the ticket resolves with a `DeadlineExceededError` (a `failure` event, not `cancelled`), which is terminal. Omit it (or pass `Infinity`) for no deadline.
|
|
300
|
+
|
|
301
|
+
The [operations guide](docs/operations.md) covers how to choose the two together.
|
|
302
|
+
|
|
303
|
+
### Bulkhead isolation
|
|
304
|
+
|
|
305
|
+
Every request is assigned to a partition, keyed by URL host by default: the hostname, plus `:port` when it isn't the scheme's default. `http://api.example.com:8080` and `http://api.example.com:9090` land in separate partitions, and a `partitions` key for an https host on the default port is just `"api.example.com"`. Each partition owns a concurrency limit plus a waiting queue. A failing host's retries fill its own partition queue, not other hosts'.
|
|
306
|
+
|
|
307
|
+
The partition's concurrency limit and queue govern only **retry traffic** — the initial attempt skips them (unless `limitFirstAttempts` is set on the partition). It still counts against the client-wide `concurrency` cap.
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
import { HttpClient } from "@vereda/http";
|
|
311
|
+
|
|
312
|
+
const client = HttpClient.create({
|
|
313
|
+
timeout: { attemptMs: 5_000 },
|
|
314
|
+
concurrency: 10, // client-wide cap across all partitions, first attempts included
|
|
315
|
+
partitions: {
|
|
316
|
+
"api.external.com": { concurrency: 2, maxQueueSize: 10 },
|
|
317
|
+
"api.internal.com": { concurrency: 20 },
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
You can assign a partition explicitly, to isolate a group of requests (its own retry bulkhead, breaker, and `partitions[name]` config) or to group hosts. It doesn't prioritize anything:
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
client.get("/path", { partition: "high-priority" });
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
When a partition's queue, or the global queue, is full, the ticket resolves with a `QueueFullError`. That is deliberate backpressure: the alternative is unbounded memory growth.
|
|
329
|
+
|
|
330
|
+
### Circuit breaker
|
|
331
|
+
|
|
332
|
+
Opt-in, per-partition. Once a host is clearly failing, stop sending it requests instead of retrying into it. Disabled by default; enable it for every partition at the client level, or for specific hosts under `partitions`.
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
import { HttpClient } from "@vereda/http";
|
|
336
|
+
|
|
337
|
+
const client = HttpClient.create({
|
|
338
|
+
timeout: { attemptMs: 5_000 },
|
|
339
|
+
circuitBreaker: {
|
|
340
|
+
enabled: true,
|
|
341
|
+
failureThreshold: 5, // consecutive failures that trip it open
|
|
342
|
+
resetTimeoutMs: 30_000, // how long to stay open before a half-open trial
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The breaker is checked before the first attempt and again before every retry — while open, requests to that partition fail with `CircuitOpenError` instead of sending the attempt that was due. After `resetTimeoutMs`, one trial request is let through (`halfOpenMaxAttempts`); success closes the circuit, another failure reopens it. Only `network`, `timeout`, and `retryable_status` errors count as failures (override with `isFailure`). Any other response, such as a 404 or a body that fails `parse`, shows the host is up and counts as a success. An attempt that never reached the host at all — a body factory that threw, for instance — is ignored instead: it carries no information about the host's health, so it can't reset a failing streak or close a half-open trial.
|
|
348
|
+
|
|
349
|
+
Trip on a rolling failure rate instead of consecutive failures:
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
import { HttpClient } from "@vereda/http";
|
|
353
|
+
|
|
354
|
+
const client = HttpClient.create({
|
|
355
|
+
timeout: { attemptMs: 5_000 },
|
|
356
|
+
circuitBreaker: {
|
|
357
|
+
enabled: true,
|
|
358
|
+
window: { sizeMs: 60_000, minimumRequests: 20, failureRatePercent: 50 },
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Typed results
|
|
364
|
+
|
|
365
|
+
Pass a `parse` function to validate and type the response body. `parse` is just `(data: unknown) => T`, and any validator that throws on failure works. A failed parse resolves the ticket with a `ValidationError` and is never retried. With `parse` set, so does a body that isn't valid JSON, including an empty one (a `204`, or any `HEAD` response): the server answered, and asking again would get the same answer.
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
const ticket = client.get<User>("/users/1", {
|
|
369
|
+
parse: (data) => data as User, // or your own throwing validator
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const result = await ticket.toPromise();
|
|
373
|
+
if (result.success) {
|
|
374
|
+
result.data.name; // typed: string
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
`json<T>()` is the built-in, dependency-free version of that cast.
|
|
379
|
+
|
|
380
|
+
#### Zod adapter (optional)
|
|
381
|
+
|
|
382
|
+
Zod is an optional peer dependency. Only the `@vereda/http/zod` entry point imports it; the core has zero dependencies. Vereda ships a Zod adapter for it:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
import { z } from "zod";
|
|
386
|
+
import { withZod } from "@vereda/http/zod";
|
|
387
|
+
|
|
388
|
+
const UserSchema = z.object({
|
|
389
|
+
id: z.number(),
|
|
390
|
+
name: z.string(),
|
|
391
|
+
email: z.email(),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const ticket = client.get("/users/1", { parse: withZod(UserSchema) });
|
|
395
|
+
|
|
396
|
+
const result = await ticket.toPromise();
|
|
397
|
+
if (result.success) {
|
|
398
|
+
result.data.name; // typed: string
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Error handling
|
|
403
|
+
|
|
404
|
+
Errors are a closed hierarchy under `RequestError`, and `AppError` is the union of all of them. Every class carries a readonly, literal-typed `kind`, so switching on it narrows to that class and its fields (`instanceof` works too):
|
|
405
|
+
|
|
406
|
+
| Error | `kind` | Meaning | Notable fields |
|
|
407
|
+
| --- | --- | --- | --- |
|
|
408
|
+
| `NetworkError` | `"network"` | Request failed before a response arrived (DNS, connection reset, etc.) | `cause` |
|
|
409
|
+
| `HttpError` | `"http"` | Non-2xx response outside `retry.retryOnStatus` (e.g. `404`) | `statusCode`, `response` |
|
|
410
|
+
| `RetryableStatusError` | `"retryable_status"` | Non-2xx response matching `retry.retryOnStatus` (e.g. `503`) | `statusCode`, `response` (status and headers only; its body was cancelled), `retryAfterMs?` |
|
|
411
|
+
| `TimeoutError` | `"timeout"` | Attempt exceeded `timeout.attemptMs` | `url`, `timeoutMs` |
|
|
412
|
+
| `DeadlineExceededError` | `"deadline"` | Ticket exceeded `timeout.totalMs` (terminal — not retried) | `url`, `totalMs` |
|
|
413
|
+
| `ValidationError` | `"validation"` | Response body failed `parse` or isn't valid JSON (terminal — never retried) | `issues`, `cause` |
|
|
414
|
+
| `CancelledError` | `"cancelled"` | Ticket cancelled or signal aborted (terminal) | — |
|
|
415
|
+
| `QueueFullError` | `"queue_full"` | A partition's retry queue, or the global queue (`partition: "global"`), was full (terminal) | `partition`, `queueSize`, `maxQueueSize` |
|
|
416
|
+
| `CircuitOpenError` | `"circuit_open"` | The partition's breaker was open when an attempt was due, so it wasn't sent; earlier attempts may have run (terminal) | `partition` |
|
|
417
|
+
| `ConfigurationError` | `"configuration"` | A relative URL with no `baseUrl`, a bare `ReadableStream` body, a body factory that threw, or invalid request-level `timeout`/`retry` options (terminal). Invalid client config throws from `create()` instead | `key` |
|
|
418
|
+
| `MaxRetriesExceededError` | `"max_retries"` | Retries ran out while the failure was still transient (terminal) | `attempts`, `lastError` |
|
|
419
|
+
|
|
420
|
+
Only `network`, `timeout`, and `retryable_status` are retried by default — see [What gets retried](#what-gets-retried) above. Everything else is terminal: it resolves the ticket on the first attempt that produces it.
|
|
421
|
+
|
|
422
|
+
`TimeoutError` and `DeadlineExceededError` can also arrive outside a `Result`: as the rejection of a body read performed later, on `result.raw` or `HttpError.response` (see the hero example above) — not only as `result.error`.
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
const result = await ticket.toPromise();
|
|
426
|
+
if (!result.success) {
|
|
427
|
+
switch (result.error.kind) {
|
|
428
|
+
case "max_retries":
|
|
429
|
+
result.error.lastError; // MaxRetriesExceededError: the final attempt's error
|
|
430
|
+
break;
|
|
431
|
+
case "http":
|
|
432
|
+
result.error.statusCode; // HttpError: also .response
|
|
433
|
+
break;
|
|
434
|
+
case "circuit_open":
|
|
435
|
+
result.error.partition; // CircuitOpenError: the partition that is failing fast
|
|
436
|
+
break;
|
|
437
|
+
default:
|
|
438
|
+
console.error(result.error.message);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Because the set is closed, a `switch` with a `const unreachable: never = result.error` default fails to compile if a case is missing.
|
|
444
|
+
|
|
445
|
+
### Cancellation
|
|
446
|
+
|
|
447
|
+
Cancel from the ticket, or wire in your own `AbortSignal`:
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
const ticket = client.get("/slow-api/data");
|
|
451
|
+
ticket.cancel();
|
|
452
|
+
|
|
453
|
+
const controller = new AbortController();
|
|
454
|
+
const ticket2 = client.get("/api/data", { signal: controller.signal });
|
|
455
|
+
controller.abort(); // ticket resolves with CancelledError
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Cancellation wins over everything else. A cancelled request is never retried.
|
|
459
|
+
|
|
460
|
+
To shut a client down, `client.close()` cancels everything in flight; `client.close({ drain: true, timeoutMs })` waits for in-flight tickets first, then cancels whatever is left. Either way, new requests throw `ConfigurationError("client closed")`. See the [shutdown sequence](docs/operations.md#shutdown-sequence) in the operations guide.
|
|
461
|
+
|
|
462
|
+
### Tickets
|
|
463
|
+
|
|
464
|
+
Every request method returns a **Ticket** synchronously — a handle to a request that may take several attempts. Most code only calls `toPromise()`. When you need to watch a request progress through its retries, or stop it partway, the ticket is also what you subscribe to and cancel.
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
const ticket = client.get("/api/data");
|
|
468
|
+
|
|
469
|
+
// Await the terminal result
|
|
470
|
+
const result = await ticket.toPromise();
|
|
471
|
+
|
|
472
|
+
// Or follow every state change
|
|
473
|
+
for await (const update of ticket.subscribe()) {
|
|
474
|
+
// { type: "queued" }
|
|
475
|
+
// { type: "retrying", attempt, delayMs }
|
|
476
|
+
// { type: "done", result }
|
|
477
|
+
// { type: "cancelled" }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Or cancel mid-flight
|
|
481
|
+
ticket.cancel();
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
The result is a discriminated union:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
type Result<T> =
|
|
488
|
+
| { success: true; data: T; raw: Response }
|
|
489
|
+
| { success: false; error: AppError };
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
A promise is a single future value. A resilient request has a lifecycle — queued, retrying, done — and a ticket exposes that lifecycle while `toPromise()` stays available for code that just wants the answer.
|
|
493
|
+
|
|
494
|
+
### Middleware
|
|
495
|
+
|
|
496
|
+
Middleware wraps every attempt (including retries) in the standard onion shape. Each middleware receives a `RequestContext` — `{ url, method, headers, body, signal, attempt, ticketId, partition }`, where `headers` is a real `Headers` instance — and a `next` function that calls the next middleware (or the actual fetch):
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
import { defaultHeaders, requestLogger } from "@vereda/http/middleware";
|
|
500
|
+
|
|
501
|
+
client.use(defaultHeaders({ Authorization: "Bearer token123" }));
|
|
502
|
+
client.use(requestLogger()); // redacts URL query values/credentials by default
|
|
503
|
+
|
|
504
|
+
client.use(async (ctx, next) => {
|
|
505
|
+
console.log("Request:", ctx.url, "attempt", ctx.attempt);
|
|
506
|
+
const response = await next(ctx);
|
|
507
|
+
console.log("Response:", response.status);
|
|
508
|
+
return response;
|
|
509
|
+
});
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Middleware can rewrite `ctx.url` before calling `next(ctx)` — whatever URL survives to the innermost middleware is what actually gets fetched. `defaultHeaders()` only sets a header the request doesn't already have; the comparison is case-insensitive, so a request-level `authorization` header always wins over a default `Authorization` one and you never end up sending both.
|
|
513
|
+
|
|
514
|
+
Middleware receives the same `AbortSignal` the request uses (`ctx.signal`), so it can participate in timeout and cancellation handling — but only if it observes or forwards that signal to the work it performs. When a response is handed back unread, the same signal can still abort later, when the body-read bound expires.
|
|
515
|
+
|
|
516
|
+
### Lifecycle events
|
|
517
|
+
|
|
518
|
+
The client emits typed events across all requests, useful for metrics, logging, and alerting. Exactly one of `success`, `failure`, or `cancelled` fires per ticket:
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
client.on("request", ({ ticketId, url, method, partition }) => {});
|
|
522
|
+
client.on("retry", ({ ticketId, url, partition, attempt, delayMs, error }) => {});
|
|
523
|
+
client.on("success", ({ ticketId, url, partition, attempts, durationMs, queuedMs, statusCode }) => {});
|
|
524
|
+
client.on("failure", ({ ticketId, url, partition, attempts, durationMs, queuedMs, error }) => {});
|
|
525
|
+
client.on("cancelled", ({ ticketId, url, partition, attempts, durationMs, queuedMs }) => {});
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
`retry`'s `attempt` is a zero-based retry index (`0` = the first retry, after the initial attempt). `off(event, listener)` removes a listener with the same signature as `on`. A listener (or `metrics` sink) that throws never affects the request: every other listener still runs, and the error is rethrown on a microtask, so it surfaces through `process.on("uncaughtException")` the same way a throwing `EventEmitter` listener would.
|
|
529
|
+
|
|
530
|
+
`queuedMs` is the total time this ticket spent waiting for a bulkhead/global-semaphore permit, summed across every attempt — it's `0` when a request never had to wait (the default global cap is 50 concurrent, so most single-service consumers never hit it). A consistently nonzero `queuedMs` relative to `durationMs` means you're throttled by `concurrency`/a partition's `concurrency`, not by downstream latency; see [Wiring a metrics sink](docs/operations.md#wiring-a-metrics-sink) for the companion `vereda.queue_depth` / `vereda.global_queue_depth` gauges.
|
|
531
|
+
|
|
532
|
+
If the circuit breaker is enabled, a partition also fires `circuitOpen`/`circuitClose` independently of any single ticket:
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
client.on("circuitOpen", ({ partition }) => {});
|
|
536
|
+
client.on("circuitClose", ({ partition }) => {});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Metrics
|
|
540
|
+
|
|
541
|
+
Pass a `metrics` sink and the client reports counters, histograms and gauges as requests run. A sink is three methods, so it's a thin adapter over OpenTelemetry, StatsD, Prometheus or whatever your service already uses:
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
import { HttpClient, type MetricsSink } from "@vereda/http";
|
|
545
|
+
|
|
546
|
+
const metrics: MetricsSink = {
|
|
547
|
+
counter: (name, value, tags) => console.log("counter", name, value, tags),
|
|
548
|
+
histogram: (name, value, tags) => console.log("histogram", name, value, tags),
|
|
549
|
+
gauge: (name, value, tags) => console.log("gauge", name, value, tags),
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const client = HttpClient.create({ timeout: { attemptMs: 5_000 }, metrics });
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
| Metric | Type | Tags |
|
|
556
|
+
| --- | --- | --- |
|
|
557
|
+
| `vereda.requests` | counter | `partition`, `method` |
|
|
558
|
+
| `vereda.retries` | counter | `partition`, `kind` (what triggered the retry) |
|
|
559
|
+
| `vereda.duration_ms` | histogram | `partition`, `kind` (`success`, `cancelled`, or the error `kind`) |
|
|
560
|
+
| `vereda.circuit_open` | counter | `partition` |
|
|
561
|
+
| `vereda.queue_depth` | gauge | `partition` |
|
|
562
|
+
| `vereda.in_flight`, `vereda.global_queue_depth` | gauge | none |
|
|
563
|
+
|
|
564
|
+
Because everything that concerns a single dependency is tagged with `partition` (its host, unless you set one), one struggling upstream gets its own line on a graph instead of being averaged into all the others. [`examples/otel.ts`](examples/otel.ts) is an OpenTelemetry adapter, and the [operations guide](docs/operations.md#wiring-a-metrics-sink) covers each metric in detail and how to read the queue-depth gauges.
|
|
565
|
+
|
|
566
|
+
## Design philosophy
|
|
567
|
+
|
|
568
|
+
**Fresh traffic comes first.** The first attempt skips the partition bulkhead, which exists to throttle *retry* pressure onto struggling hosts — that's where thundering herds come from.
|
|
569
|
+
|
|
570
|
+
**Backpressure beats unbounded queues.** When a queue is full, fail explicitly rather than consuming infinite memory.
|
|
571
|
+
|
|
572
|
+
**Cancellation is final.** A cancelled request never enters the retry loop, regardless of timeout or retry configuration.
|
|
573
|
+
|
|
574
|
+
**Validation failures aren't transient.** A response that fails your `parse` function resolves immediately — retrying would parse the same payload again.
|
|
575
|
+
|
|
576
|
+
**No silent infinite waits.** The per-attempt timeout is the one setting without a default, because a missing timeout is the failure you only find in production.
|
|
577
|
+
|
|
578
|
+
## Documentation
|
|
579
|
+
|
|
580
|
+
- **[Operations guide](docs/operations.md)** — sizing concurrency and queues, `attemptMs` vs. `totalMs`, reading `partitions()`, wiring a metrics sink, the shutdown sequence, and log redaction.
|
|
581
|
+
- **[API reference](https://riosgabriel.github.io/vereda/)** — generated from source via TypeDoc on every push to `main`; every public option documents its default.
|
|
582
|
+
|
|
583
|
+
## Versioning and support
|
|
584
|
+
|
|
585
|
+
Vereda follows [Semantic Versioning](https://semver.org/) from `1.0.0` onward: breaking changes land only in a major version, and anything scheduled for removal is deprecated in a minor release first and noted in [CHANGELOG.md](CHANGELOG.md) before it goes. The public surface is exactly what `src/core/index.ts`, `src/middleware/index.ts`, and `src/adapters/zod.ts` export — anything under `src/queue/` and `src/ticket/` that those entry points don't re-export is internal, even though it's readable source.
|
|
586
|
+
|
|
587
|
+
**Node support:** the currently supported line is whatever `engines.node` in `package.json` declares (`>=22` today); CI runs the full suite against Node 22 and 24 on every change, so those two are the versions actually verified. The floor moves only in a major release.
|
|
588
|
+
|
|
589
|
+
## Contributing
|
|
590
|
+
|
|
591
|
+
New to Vereda? Two on-ramps:
|
|
592
|
+
|
|
593
|
+
- **Self-guided** — read [ONBOARDING.md](ONBOARDING.md), a tour that follows one request through the library.
|
|
594
|
+
- **Interactive** — run the **`guide-me`** skill in your coding harness (Claude Code, OpenCode, etc.). It's bundled in the repo and walks you through the internals interactively.
|
|
595
|
+
|
|
596
|
+
When you're ready, read [CONTRIBUTING.md](CONTRIBUTING.md) for setup, commands, and the behavioral invariants your change must preserve. Tests are self-contained: no network, services, or env vars needed.
|
|
597
|
+
|
|
598
|
+
## Why the name?
|
|
599
|
+
|
|
600
|
+
**Vereda** is Brazilian Portuguese for a narrow trail: a resilient route through terrain. That maps directly to what the library does: give your requests a reliable path through flaky networks, retries, and backpressure. *veh-REH-da.*
|
|
601
|
+
|
|
602
|
+
## License
|
|
603
|
+
|
|
604
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ZodType } from "zod";
|
|
2
|
+
import type { ParseFn } from "../core/types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Wraps a Zod schema into a ParseFn for use with vereda's `parse` option.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const UserSchema = z.object({ id: z.number(), name: z.string() });
|
|
8
|
+
*
|
|
9
|
+
* const ticket = client.get('/users/1', {
|
|
10
|
+
* parse: withZod(UserSchema),
|
|
11
|
+
* });
|
|
12
|
+
*/
|
|
13
|
+
export declare function withZod<T>(schema: ZodType<T>): ParseFn<T>;
|
|
14
|
+
//# sourceMappingURL=zod.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zod.d.ts","sourceRoot":"","sources":["../../src/adapters/zod.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC;AACnC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEhD;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAEzD"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps a Zod schema into a ParseFn for use with vereda's `parse` option.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* const UserSchema = z.object({ id: z.number(), name: z.string() });
|
|
6
|
+
*
|
|
7
|
+
* const ticket = client.get('/users/1', {
|
|
8
|
+
* parse: withZod(UserSchema),
|
|
9
|
+
* });
|
|
10
|
+
*/
|
|
11
|
+
export function withZod(schema) {
|
|
12
|
+
return (data) => schema.parse(data);
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=zod.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zod.js","sourceRoot":"","sources":["../../src/adapters/zod.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,MAAM,UAAU,OAAO,CAAI,MAAkB;IAC5C,OAAO,CAAC,IAAa,EAAK,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACjD,CAAC"}
|