@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.
- package/api-surface.md +4 -4
- package/docs/about.md +4 -4
- package/docs/changelog.md +122 -6
- package/docs/client/index.md +277 -70
- package/docs/contributing.md +20 -2
- package/docs/errors.md +22 -0
- package/docs/flow/components.md +4 -8
- package/docs/flow/decorators.md +17 -13
- package/docs/flow/events.md +3 -3
- package/docs/flow/icons.md +199 -0
- package/docs/flow/index.md +11 -7
- package/docs/flow/layouts.md +1 -1
- package/docs/flow/lifecycle.md +31 -16
- package/docs/flow/models.md +284 -0
- package/docs/flow/performance.md +6 -6
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +14 -2
- package/docs/getting-started.md +9 -9
- package/docs/i18n.md +3 -3
- package/docs/inertia/index.md +6 -1
- package/docs/inertia/props.md +1 -1
- package/docs/inertia/rendering.md +79 -0
- package/docs/routing.md +11 -0
- package/docs/support-policy.md +28 -5
- package/docs/upgrade.md +2 -0
- package/package.json +3 -3
- package/docs/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
|
@@ -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.
|
package/docs/client/testing.md
DELETED
|
@@ -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.
|