@zerotal/arch 1.7.2 → 1.7.4

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.
@@ -1,141 +0,0 @@
1
- ---
2
- title: Client Resilience
3
- description: Timeouts, retries, and the circuit breaker that spares a failing upstream.
4
- ---
5
-
6
- # Timeouts & retries
7
-
8
- Set a default `timeout` (ms) and/or a `retry` policy on the client, and override either per request:
9
-
10
- ```ts
11
- // app/api/client.ts
12
- const api = createApiClient<Routes>({
13
- timeout: 10_000,
14
- retry: 2, // or { attempts, methods, statuses, backoff, respectRetryAfter }
15
- });
16
-
17
- await api.get("/api/users", undefined, { timeout: 2_000, retry: false });
18
- ```
19
-
20
- Retries apply to **idempotent** methods by default (GET/PUT/DELETE/HEAD/OPTIONS) and trigger on
21
- network errors, request timeouts, and `408 / 425 / 429 / 5xx` responses, using exponential backoff
22
- with jitter and honoring any `Retry-After` header. A timeout aborts via `AbortSignal`; pass your
23
- own `signal` to cancel manually (it's combined with the timeout).
24
-
25
- > **Warning** — A caller-cancelled request (`AbortError`) is never retried, but a request that
26
- > times out (`TimeoutError`) is treated as transient and retried per your policy.
27
-
28
- ## Circuit breaker
29
-
30
- Attach a circuit breaker to stop hammering a struggling upstream service and fail
31
- fast instead. When the circuit is open, requests throw `CircuitBreakerOpenError`
32
- immediately — no network call is made.
33
-
34
- ```ts
35
- // app/api/client.ts
36
- import { createApiClient } from "@zerotal/client";
37
-
38
- // Option 1 — options object (creates a dedicated breaker)
39
- const api = createApiClient<Routes>({
40
- baseUrl: "https://api.example.com",
41
- circuitBreaker: {
42
- threshold: 5, // open after 5 consecutive failures
43
- resetTimeout: 30_000, // attempt recovery after 30 s
44
- },
45
- });
46
-
47
- // Option 2 — shared instance (multiple clients trip the same circuit)
48
- import { CircuitBreaker } from "@zerotal/client";
49
-
50
- const breaker = new CircuitBreaker({ threshold: 3, resetTimeout: 10_000 });
51
-
52
- const usersApi = createApiClient<Routes>({ baseUrl: "…", circuitBreaker: breaker });
53
- const postsApi = createApiClient<Routes>({ baseUrl: "…", circuitBreaker: breaker });
54
- ```
55
-
56
- ### Which option should I use?
57
-
58
- - **Options object** — one client talks to one upstream. Each client gets its own dedicated breaker.
59
- - **Shared `CircuitBreaker` instance** — several clients hit the _same_ upstream and should trip together as a unit.
60
-
61
- ```ts
62
- // in any frontend module
63
- import { CircuitBreakerOpenError } from "@zerotal/client";
64
-
65
- try {
66
- await api.get("/api/users");
67
- } catch (err) {
68
- if (err instanceof CircuitBreakerOpenError) {
69
- // Return cached data, show degraded UI, etc.
70
- }
71
- }
72
- ```
73
-
74
- ### Failure classification
75
-
76
- By default:
77
-
78
- - **5xx responses** → counted as failures (upstream is unhealthy)
79
- - **4xx responses** → not counted (client mistakes, not upstream outages)
80
- - **Network / DNS / timeout errors** → counted as failures
81
-
82
- Override with a custom predicate:
83
-
84
- ```ts
85
- // in any frontend module
86
- new CircuitBreaker({
87
- isFailure: (err) => {
88
- if (err instanceof ApiClientError) return err.status >= 500;
89
- return true; // any non-ApiClientError (network failure) counts
90
- },
91
- });
92
- ```
93
-
94
- ## Standalone CircuitBreaker
95
-
96
- `CircuitBreaker` can wrap any async operation — not just HTTP requests.
97
-
98
- ```ts
99
- // in any module
100
- import { CircuitBreaker, CircuitBreakerOpenError } from "@zerotal/client";
101
-
102
- const breaker = new CircuitBreaker({ threshold: 3, resetTimeout: 15_000 });
103
-
104
- async function fetchPricing() {
105
- return breaker.call(async () => {
106
- const res = await fetch("https://pricing.internal/v1/rates");
107
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
108
- return res.json();
109
- });
110
- }
111
- ```
112
-
113
- ### State machine
114
-
115
- | State | Behaviour |
116
- | ----------- | ------------------------------------------------------- |
117
- | `closed` | Requests pass through; consecutive failures are counted |
118
- | `open` | All calls immediately throw `CircuitBreakerOpenError` |
119
- | `half-open` | One probe request is allowed through to test recovery |
120
-
121
- Transitions:
122
-
123
- - **closed → open** when failure count reaches `threshold`
124
- - **open → half-open** after `resetTimeout` ms
125
- - **half-open → closed** on a successful probe
126
- - **half-open → open** on a failed probe (timer resets)
127
-
128
- While in `half-open`, concurrent calls that arrive before the probe completes also
129
- throw `CircuitBreakerOpenError` — only one probe goes through at a time.
130
-
131
- ```ts
132
- // in any module
133
- breaker.state; // 'closed' | 'open' | 'half-open'
134
- breaker.failures; // current consecutive failure count
135
- breaker.reset(); // manually reset to closed (e.g. after an admin action)
136
- ```
137
-
138
- ## Next steps
139
-
140
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
141
- - [Reference](/docs/client/references) — the full API surface in one table.
@@ -1,146 +0,0 @@
1
- ---
2
- title: Testing the Client
3
- description: Stub the global fetch, cover the failure paths, and drive the circuit breaker.
4
- ---
5
-
6
- # Testing
7
-
8
- Set your suite up once as described in [Testing](/docs/testing). `ApiClient`
9
- calls the global `fetch`, so a test controls it by replacing that — there is no
10
- separate fake to install.
11
-
12
- ```typescript
13
- // tests/services/BillingClient.test.ts
14
- import { test, expect, afterEach } from "bun:test";
15
- import { ApiClient } from "@zerotal/client";
16
-
17
- const realFetch = globalThis.fetch;
18
- afterEach(() => {
19
- globalThis.fetch = realFetch;
20
- });
21
-
22
- test("maps a customer payload onto our shape", async () => {
23
- globalThis.fetch = async () =>
24
- new Response(JSON.stringify({ id: "cus_1", email: "jane@example.com" }), {
25
- status: 200,
26
- headers: { "Content-Type": "application/json" },
27
- });
28
-
29
- const client = new ApiClient({ baseUrl: "https://api.example.com" });
30
- const customer = await client.get("/customers/cus_1");
31
-
32
- expect(customer.email).toBe("jane@example.com");
33
- });
34
- ```
35
-
36
- **Restore `fetch` in `afterEach`, not at the end of the test.** A test that
37
- throws before its cleanup line leaves the stub installed, and every later test in
38
- the file talks to it instead of the network — producing failures that point at
39
- innocent code.
40
-
41
- ## Asserting on the request
42
-
43
- Stubbing the response proves your code reads the answer correctly. It says nothing
44
- about whether you asked the right question — a client that sends the wrong URL,
45
- loses a query parameter, or drops the auth header passes every response-shaped test
46
- and still fails in production. Capture what `fetch` received and assert on it:
47
-
48
- ```typescript
49
- // tests/services/BillingClient.test.ts
50
- test("sends the query and the auth header", async () => {
51
- let url: string | undefined;
52
- let init: RequestInit | undefined;
53
-
54
- globalThis.fetch = async (u, i) => {
55
- url = String(u);
56
- init = i;
57
- return new Response("[]", { status: 200 });
58
- };
59
-
60
- const client = new ApiClient({
61
- baseUrl: "https://api.example.com",
62
- token: "tok_123",
63
- });
64
- await client.get("/customers", { status: "active" });
65
-
66
- expect(url).toBe("https://api.example.com/customers?status=active");
67
- expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer tok_123");
68
- });
69
- ```
70
-
71
- Wrap the headers in `new Headers(...)` before reading them. Casing is normalised
72
- that way, so the assertion holds whether the value arrived as a plain object or as
73
- a `Headers` instance from a per-request override.
74
-
75
- Uploads deserve one such test each. Asserting that `init.body` is the `FormData`
76
- you passed — rather than a JSON string — is what pins down that the body was not
77
- re-encoded on its way out.
78
-
79
- **Test the failure paths, because they are the ones production hits.** A 500, a
80
- timeout, and a malformed body all reach your code differently:
81
-
82
- ```typescript
83
- // tests/services/BillingClient.test.ts
84
- test("a 500 surfaces as ApiClientError", async () => {
85
- globalThis.fetch = async () => new Response("upstream boom", { status: 500 });
86
-
87
- await expect(client.get("/customers/cus_1")).rejects.toBeInstanceOf(ApiClientError);
88
- });
89
- ```
90
-
91
- A network failure is a different case again, and the stub models it by rejecting
92
- instead of resolving — which is how you reach the branch that never receives a
93
- status at all:
94
-
95
- ```typescript
96
- globalThis.fetch = async () => {
97
- throw new TypeError("Failed to fetch");
98
- };
99
-
100
- await expect(client.get("/customers")).rejects.not.toBeInstanceOf(ApiClientError);
101
- ```
102
-
103
- ## Retries and the circuit breaker
104
-
105
- A retry policy is invisible from the outside — the caller sees one resolved promise
106
- whether it took one attempt or four. Counting calls is what makes the behaviour
107
- observable:
108
-
109
- ```typescript
110
- // tests/services/BillingClient.test.ts
111
- let calls = 0;
112
- globalThis.fetch = async () => {
113
- calls++;
114
- return calls < 3 ? new Response("", { status: 503 }) : new Response("{}", { status: 200 });
115
- };
116
-
117
- await client.get("/customers");
118
- expect(calls).toBe(3); // two failures, then success
119
- ```
120
-
121
- **A circuit breaker is a state machine**, so drive it with repeated failures and
122
- assert it opens — then that it refuses without calling `fetch` at all:
123
-
124
- ```typescript
125
- // tests/services/BillingClient.test.ts
126
- let calls = 0;
127
- globalThis.fetch = async () => {
128
- calls++;
129
- return new Response("", { status: 500 });
130
- };
131
-
132
- for (let i = 0; i < threshold; i++) await client.get("/x").catch(() => {});
133
- const before = calls;
134
-
135
- await expect(client.get("/x")).rejects.toBeInstanceOf(CircuitBreakerOpenError);
136
- expect(calls).toBe(before); // the open circuit short-circuits, no request made
137
- ```
138
-
139
- That last assertion is the point of a breaker — without it you have tested that
140
- an error is thrown, not that the upstream was spared.
141
-
142
- ## Next steps
143
-
144
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
145
- - [Resilience](/docs/client/resilience) — the retry and breaker policies under test.
146
- - [Error handling](/docs/client/errors) — the errors these tests assert on.