opencode-effect-enforcer 0.2.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 +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-http-client
|
|
3
|
+
description: Make outgoing HTTP requests with Effect's HttpClient — HttpClientRequest builders, schema-decoded HttpClientResponse bodies, the HttpClientError taxonomy, retryTransient/rate limiting/cookies/redirects, streaming uploads and downloads, and FetchHttpClient/NodeHttpClient transport layers. Use when calling external REST/JSON APIs, uploading or downloading files and streams, adding retries/auth/tracing to outbound HTTP, or mocking HTTP responses in tests.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an Effect TypeScript expert specializing in outgoing HTTP with `effect/unstable/http` (`HttpClient`, `HttpClientRequest`, `HttpClientResponse`).
|
|
7
|
+
|
|
8
|
+
In v4 there is no `@effect/platform` package — the HTTP client lives in the `effect` package under `effect/unstable/http`. Only the platform transports (`NodeHttpClient`, `BunHttpClient`) live in `@effect/platform-*` packages.
|
|
9
|
+
|
|
10
|
+
## Effect Source Reference
|
|
11
|
+
|
|
12
|
+
The Effect v4 source is at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`. Read it directly when in doubt — these modules are `unstable` and change between betas.
|
|
13
|
+
|
|
14
|
+
Key files:
|
|
15
|
+
|
|
16
|
+
- `packages/effect/src/unstable/http/HttpClient.ts` — the `HttpClient` service, `make`/`makeWith`, every client combinator (`mapRequest`, `transform`, `filterStatus*`, `retry`, `retryTransient`, `withRateLimiter`, `withCookiesRef`, `withScope`, `followRedirects`, `catch*`, `tap*`), tracing references
|
|
17
|
+
- `packages/effect/src/unstable/http/HttpClientRequest.ts` — immutable request model, method constructors, URL/param/header/body combinators, `toWeb`/`fromWeb`
|
|
18
|
+
- `packages/effect/src/unstable/http/HttpClientResponse.ts` — response model, `schemaJson`/`schemaNoBody`, `matchStatus`, `filterStatus(Ok)`, `stream`
|
|
19
|
+
- `packages/effect/src/unstable/http/HttpIncomingMessage.ts` — shared body accessors plus `JsonOptions`, `schemaBodyJson`, `schemaBodyUrlParams`, `schemaHeaders` (re-exported by HttpClientResponse)
|
|
20
|
+
- `packages/effect/src/unstable/http/HttpClientError.ts` — `HttpClientError` wrapper and its `reason` union
|
|
21
|
+
- `packages/effect/src/unstable/http/HttpBody.ts` — body variants (`Empty`, `Raw`, `Uint8Array`, `FormData`, `Stream`) and constructors (`json`, `jsonSchema`, `text`, `urlParams`, `formDataRecord`, `stream`, `file`)
|
|
22
|
+
- `packages/effect/src/unstable/http/FetchHttpClient.ts` — fetch transport: `layer`, `Fetch` reference, `RequestInit` service
|
|
23
|
+
- `packages/effect/src/unstable/http/UrlParams.ts` — ordered query-param model, coercion rules, schemas
|
|
24
|
+
- `packages/effect/src/unstable/http/Url.ts` — immutable helpers over the native `URL`
|
|
25
|
+
- `packages/effect/src/unstable/http/Cookies.ts` — cookie model, `fromSetCookie`, `toCookieHeader`, `getValue`
|
|
26
|
+
- `packages/effect/src/unstable/http/Headers.ts` — header model, `Input` forms, `CurrentRedactedNames`
|
|
27
|
+
- `packages/platform-node/src/NodeHttpClient.ts` — Node transports: undici, node:http, fetch re-export
|
|
28
|
+
- `packages/effect/test/unstable/http/HttpClient.test.ts` — retryTransient, withRateLimiter, abort semantics
|
|
29
|
+
- `ai-docs/src/50_http-client/10_basics.ts` — canonical "wrap a configured client in a service" lesson
|
|
30
|
+
|
|
31
|
+
## Core Model
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
HttpClientRequest ──► client.execute ──► Effect<HttpClientResponse, HttpClientError, R>
|
|
35
|
+
(immutable value) (HttpClient service) (body accessors are Effects)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
An `HttpClient.With<E, R>` is a pair of functions — `preprocess` (request → request, effectful) and `postprocess` (request effect → response effect) — plus `execute` and per-method helpers (`get`, `post`, `put`, `patch`, `del`, `head`, `options`). The default service type is `HttpClient = HttpClient.With<HttpClientError, never>`. Every combinator (`mapRequest`, `filterStatusOk`, `retryTransient`, ...) returns a **new client value**; clients are immutable and cheap to derive, so build one configured client per upstream API and share it.
|
|
39
|
+
|
|
40
|
+
### Application Boundary Policy
|
|
41
|
+
|
|
42
|
+
- Runtime application and provider integrations use `HttpClient`; do not call raw `fetch` from business or provider code.
|
|
43
|
+
- A raw `fetch` call is permitted only in an explicitly named low-level platform adapter that owns transport interop and documents why an Effect transport cannot be used. Lift it with `Effect.tryPromise`, pass the supplied `AbortSignal` to fetch, and do not let `Request`, `Response`, rejected promises, or untyped payloads escape that adapter.
|
|
44
|
+
- Give each upstream adapter a named service and named effects that own request construction, authentication, execution, status classification, schema decoding, and error mapping.
|
|
45
|
+
- Read credentials with `Config.redacted` and attach them in a configured client transform; never pass raw secret strings through business workflows.
|
|
46
|
+
- Classify status before decoding a success schema. Non-2xx error bodies often have a different shape and must not be decoded as successful payloads.
|
|
47
|
+
- Decode external response data with `HttpClientResponse.schemaBodyJson`, `schemaJson`, or another `Schema` decoder. A successful JSON parse is not validation.
|
|
48
|
+
- Preserve bounded diagnostic evidence such as provider request IDs, status, error codes, and retry metadata. Redact credentials, authorization headers, query secrets, private payload fields, and full bodies before logging or storing evidence.
|
|
49
|
+
- Run provider/network calls outside database transactions. Acquire remote results first, then open the shortest transaction needed to persist them; never hold a database transaction open across latency, retries, or rate-limit waits.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { Effect, Layer, Redacted, Ref, Schedule, Schema, Stream } from 'effect';
|
|
53
|
+
import {
|
|
54
|
+
Cookies,
|
|
55
|
+
FetchHttpClient,
|
|
56
|
+
Headers,
|
|
57
|
+
HttpBody,
|
|
58
|
+
HttpClient,
|
|
59
|
+
HttpClientError,
|
|
60
|
+
HttpClientRequest,
|
|
61
|
+
HttpClientResponse,
|
|
62
|
+
UrlParams
|
|
63
|
+
} from 'effect/unstable/http';
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For Node-specific transports:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { NodeHttpClient } from '@effect/platform-node';
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 1. Providing an HttpClient
|
|
75
|
+
|
|
76
|
+
The service tag is `HttpClient.HttpClient` (a `Context.Service`). Provide one transport layer:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// Browser / edge / anywhere globalThis.fetch exists
|
|
80
|
+
const FetchLayer = FetchHttpClient.layer; // Layer<HttpClient.HttpClient>
|
|
81
|
+
|
|
82
|
+
// Node.js — pick one:
|
|
83
|
+
NodeHttpClient.layerUndici; // undici Agent (fast; the usual choice on Node)
|
|
84
|
+
NodeHttpClient.layerNodeHttp; // node:http/https with default Agents (keepAlive via layerAgentOptions)
|
|
85
|
+
NodeHttpClient.layerFetch; // re-export of FetchHttpClient.layer
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`@effect/platform-bun`'s `BunHttpClient` simply re-exports `FetchHttpClient`.
|
|
89
|
+
|
|
90
|
+
Keep the dependency graph visible in adapter modules. Export a raw `layer` that still requires `HttpClient.HttpClient`, then optionally export `defaultLayer` for runtime convenience:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
export class Todos extends Context.Service<Todos, {
|
|
94
|
+
readonly getTodo: (id: number) => Effect.Effect<string>;
|
|
95
|
+
}>()('app/Todos') {
|
|
96
|
+
static readonly layer: Layer.Layer<Todos, never, HttpClient.HttpClient> =
|
|
97
|
+
Layer.effect(
|
|
98
|
+
Todos,
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
yield* HttpClient.HttpClient;
|
|
101
|
+
return Todos.of({ getTodo: (id) => Effect.succeed(`todo-${id}`) });
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
static readonly defaultLayer: Layer.Layer<Todos> = Todos.layer.pipe(
|
|
106
|
+
Layer.provide(FetchHttpClient.layer)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Tests and application composition can provide a mock or a different transport to `layer`; only callers intentionally choosing the bundled transport use `defaultLayer`. Do not hide the transport requirement inside the raw layer.
|
|
112
|
+
|
|
113
|
+
### Using the client
|
|
114
|
+
|
|
115
|
+
Either grab the service and call its methods, or use the module-level accessors (which require `HttpClient.HttpClient` in `R`):
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
class Todo extends Schema.Class<Todo>('Todo')({
|
|
119
|
+
id: Schema.Number,
|
|
120
|
+
title: Schema.String
|
|
121
|
+
}) {}
|
|
122
|
+
|
|
123
|
+
const program = Effect.gen(function* () {
|
|
124
|
+
const client = yield* HttpClient.HttpClient;
|
|
125
|
+
return yield* client.get('https://api.example.com/todos/1').pipe(
|
|
126
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
127
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
|
|
128
|
+
);
|
|
129
|
+
}).pipe(Effect.provide(FetchHttpClient.layer));
|
|
130
|
+
|
|
131
|
+
// Accessor form — same thing, R = HttpClient.HttpClient
|
|
132
|
+
const quick = HttpClient.get('https://api.example.com/todos/1').pipe(
|
|
133
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
134
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
|
|
135
|
+
);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Method helpers accept `(url: string | URL, options?: HttpClientRequest.Options.NoUrl)`:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
client.get('https://api.example.com/todos', {
|
|
142
|
+
urlParams: { page: 1, completed: true }, // numbers/booleans coerced
|
|
143
|
+
headers: { 'x-request-id': 'abc' },
|
|
144
|
+
acceptJson: true // sets Accept: application/json
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Full options keys: `urlParams`, `hash`, `headers`, `body` (an `HttpBody`), `accept`, `acceptJson` (no `method`/`url` — those come from the helper). The DELETE helper on clients/accessors is `del`.
|
|
149
|
+
|
|
150
|
+
### Fetch configuration
|
|
151
|
+
|
|
152
|
+
`FetchHttpClient.Fetch` is a `Context.Reference` for the fetch function (default `globalThis.fetch`); `FetchHttpClient.RequestInit` is a service holding default fetch options. Services provided **to** the layer are visible to the transport (via `HttpClient.layerMergedContext`):
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const CorsClientLayer = FetchHttpClient.layer.pipe(
|
|
156
|
+
Layer.provide(
|
|
157
|
+
Layer.succeed(FetchHttpClient.RequestInit, { credentials: 'include' })
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Note: the fetch transport strips any `content-length` header and sends `Stream` bodies with `duplex: 'half'`.
|
|
163
|
+
|
|
164
|
+
### Node transport configuration
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import * as Undici from 'undici';
|
|
168
|
+
|
|
169
|
+
// Undici: custom dispatcher, or reuse the global one
|
|
170
|
+
NodeHttpClient.layerUndiciNoDispatcher; // requires NodeHttpClient.Dispatcher
|
|
171
|
+
NodeHttpClient.layerDispatcher; // scoped new Undici.Agent
|
|
172
|
+
NodeHttpClient.dispatcherLayerGlobal; // undici's global dispatcher
|
|
173
|
+
NodeHttpClient.UndiciOptions; // Context.Reference<Partial<Dispatcher.RequestOptions>>
|
|
174
|
+
|
|
175
|
+
// e.g. route every request through a proxy
|
|
176
|
+
const ProxyClientLayer = NodeHttpClient.layerUndiciNoDispatcher.pipe(
|
|
177
|
+
Layer.provide(Layer.succeed(NodeHttpClient.Dispatcher, new Undici.ProxyAgent(proxyUrl)))
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// node:http: custom agents (keepAlive, maxSockets, TLS options...)
|
|
181
|
+
const AgentClientLayer = NodeHttpClient.layerNodeHttpNoAgent.pipe(
|
|
182
|
+
Layer.provide(NodeHttpClient.layerAgentOptions({ keepAlive: true, maxSockets: 64 }))
|
|
183
|
+
);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The undici transport sets `headersTimeout` to one hour and disables `bodyTimeout`, leaving timeouts to `Effect.timeout` (section 7).
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 2. Building Requests
|
|
191
|
+
|
|
192
|
+
`HttpClientRequest` is an immutable value: `{ method, url, urlParams, hash, headers, body }`. Nothing happens until a client executes it.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
// Constructors — one per method; note `delete`, not `del`, in this module
|
|
196
|
+
HttpClientRequest.get('https://api.example.com/users');
|
|
197
|
+
HttpClientRequest.post('/users'); // relative; pair with HttpClient.mapRequest(prependUrl(...))
|
|
198
|
+
HttpClientRequest.put('/users/1');
|
|
199
|
+
HttpClientRequest.patch('/users/1');
|
|
200
|
+
HttpClientRequest.delete('/users/1');
|
|
201
|
+
HttpClientRequest.head('/users');
|
|
202
|
+
HttpClientRequest.options('/users');
|
|
203
|
+
HttpClientRequest.trace('/debug');
|
|
204
|
+
|
|
205
|
+
// All constructors accept the same options bag as the client method helpers
|
|
206
|
+
HttpClientRequest.get('/search', { urlParams: { q: 'effect' }, acceptJson: true });
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
These come from the generic factory `HttpClientRequest.make(method)`; `HttpClientRequest.setMethod` swaps the method on an existing request. `HttpMethod` is a closed union of these eight verbs — there is no path for custom methods like `REPORT`.
|
|
210
|
+
|
|
211
|
+
### URL combinators
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
request.pipe(
|
|
215
|
+
HttpClientRequest.setUrl('https://api.example.com/v2'), // URL object input extracts search/hash
|
|
216
|
+
HttpClientRequest.prependUrl('https://api.example.com'), // joins with exactly one '/'
|
|
217
|
+
HttpClientRequest.appendUrl('/comments'),
|
|
218
|
+
HttpClientRequest.updateUrl((url) => url.replace('/v1/', '/v2/')),
|
|
219
|
+
HttpClientRequest.setHash('section') // no leading '#'
|
|
220
|
+
);
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Query parameters
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
request.pipe(
|
|
227
|
+
HttpClientRequest.setUrlParam('page', '2'), // replace values for one key
|
|
228
|
+
HttpClientRequest.setUrlParams({ sort: 'desc', limit: 50 }), // replace per key
|
|
229
|
+
HttpClientRequest.appendUrlParam('tag', 'a'), // keep existing values
|
|
230
|
+
HttpClientRequest.appendUrlParams({ tag: ['b', 'c'] })
|
|
231
|
+
);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`UrlParams.Input` accepts records, iterables of `[key, value]` tuples, or `URLSearchParams`. Values may be `string | number | bigint | boolean | null | undefined`; `undefined` entries are **skipped** (great for optional params), arrays produce repeated keys, and nested records render with bracket notation (`filter[name]=x`).
|
|
235
|
+
|
|
236
|
+
For standalone `UrlParams` values (e.g. `response.urlParamsBody`) the module mirrors these combinators: `UrlParams.getFirst`/`getAll`, `set`/`append`/`setAll`/`appendAll`, `toRecord`. In schema pipelines, `UrlParams.schemaRecord` decodes params into a record (`schemaBodyUrlParams` wraps it) and `UrlParams.schemaJsonField(name, { reviver })` parses one field's value as JSON with an optional `JSON.parse` reviver.
|
|
237
|
+
|
|
238
|
+
### Headers and auth
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
request.pipe(
|
|
242
|
+
HttpClientRequest.setHeader('x-api-version', '2024-01-01'),
|
|
243
|
+
HttpClientRequest.setHeaders({ 'x-a': '1', 'x-b': ['v1', 'v2'] }), // arrays join with ', '
|
|
244
|
+
HttpClientRequest.updateHeaders((headers) => Headers.set(headers, 'x-trace', '1')),
|
|
245
|
+
HttpClientRequest.removeHeader('x-obsolete'),
|
|
246
|
+
HttpClientRequest.accept('application/vnd.api+json'),
|
|
247
|
+
HttpClientRequest.acceptJson,
|
|
248
|
+
HttpClientRequest.bearerToken(Redacted.make('secret-token')), // string | Redacted
|
|
249
|
+
HttpClientRequest.basicAuth('user', Redacted.make('pass')) // string | Redacted each
|
|
250
|
+
);
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Header names are normalized to lowercase. All of these are dual (data-first and data-last).
|
|
254
|
+
|
|
255
|
+
### Web interop
|
|
256
|
+
|
|
257
|
+
`HttpClientRequest.fromWeb(webRequest)` converts a Web `Request` for pass-through proxying; `toWeb`/`toWebResult` convert back — `Stream` bodies become `ReadableStream`s using the ambient context (`toWebResult` accepts a `context` option).
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## 3. Request Bodies
|
|
262
|
+
|
|
263
|
+
Body combinators delegate to `HttpBody` constructors and update `content-type` / `content-length` headers from the body metadata.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// Plain text / bytes — synchronous
|
|
267
|
+
HttpClientRequest.bodyText('hello', 'text/plain');
|
|
268
|
+
HttpClientRequest.bodyUint8Array(bytes, 'application/octet-stream');
|
|
269
|
+
|
|
270
|
+
// JSON — bodyJson is EFFECTFUL (JSON.stringify can fail) and returns
|
|
271
|
+
// Effect<HttpClientRequest, HttpBodyError>
|
|
272
|
+
const requestEffect = HttpClientRequest.post('/todos').pipe(
|
|
273
|
+
HttpClientRequest.bodyJson({ title: 'buy milk' })
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// bodyJsonUnsafe is synchronous but throws on unserializable input
|
|
277
|
+
HttpClientRequest.post('/todos').pipe(HttpClientRequest.bodyJsonUnsafe({ title: 'buy milk' }));
|
|
278
|
+
|
|
279
|
+
// Schema-encoded JSON — factory takes the schema, returns a dual combinator.
|
|
280
|
+
// Encoding failures surface as HttpBodyError ({ _tag: 'SchemaError', issue }).
|
|
281
|
+
class Todo extends Schema.Class<Todo>('Todo')({
|
|
282
|
+
title: Schema.String,
|
|
283
|
+
completed: Schema.Boolean
|
|
284
|
+
}) {}
|
|
285
|
+
const withBody = HttpClientRequest.post('/todos').pipe(
|
|
286
|
+
HttpClientRequest.schemaBodyJson(Todo)(
|
|
287
|
+
new Todo({ title: 'buy milk', completed: false })
|
|
288
|
+
)
|
|
289
|
+
); // Effect<HttpClientRequest, HttpBodyError, EncodingServices>
|
|
290
|
+
|
|
291
|
+
// application/x-www-form-urlencoded
|
|
292
|
+
HttpClientRequest.bodyUrlParams({ username: 'u', password: 'p' });
|
|
293
|
+
|
|
294
|
+
// multipart/form-data — boundary/content-type left to the runtime
|
|
295
|
+
HttpClientRequest.bodyFormData(existingFormData);
|
|
296
|
+
HttpClientRequest.bodyFormDataRecord({
|
|
297
|
+
title: 'Report',
|
|
298
|
+
tags: ['a', 'b'], // arrays append repeated entries
|
|
299
|
+
file: new File([bytes], 'report.pdf', { type: 'application/pdf' }),
|
|
300
|
+
skipped: undefined // nullish values are skipped
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Streaming body
|
|
304
|
+
HttpClientRequest.bodyStream(byteStream, {
|
|
305
|
+
contentType: 'application/octet-stream',
|
|
306
|
+
contentLength: knownSize // optional; omit for chunked upload
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// File body — stats the file for content-length; requires FileSystem
|
|
310
|
+
// Effect<HttpClientRequest, PlatformError, FileSystem.FileSystem>
|
|
311
|
+
HttpClientRequest.post('/upload').pipe(
|
|
312
|
+
HttpClientRequest.bodyFile('./video.mp4', { contentType: 'video/mp4' })
|
|
313
|
+
);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
To execute a request built with an effectful body combinator, `flatMap` into `client.execute`:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
const created = HttpClientRequest.post('/todos').pipe(
|
|
320
|
+
HttpClientRequest.schemaBodyJson(Todo)(todo),
|
|
321
|
+
Effect.flatMap(client.execute),
|
|
322
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
323
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
|
|
324
|
+
);
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## 4. Reading Responses
|
|
330
|
+
|
|
331
|
+
`HttpClientResponse` exposes `request`, `status`, `headers`, `cookies`, `remoteAddress`, and **Effect-valued body getters** (they are properties, not methods):
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
const program = Effect.gen(function* () {
|
|
335
|
+
const response = yield* client.get('/todos/1');
|
|
336
|
+
|
|
337
|
+
response.status; // number
|
|
338
|
+
response.headers; // Headers (lowercased record)
|
|
339
|
+
response.cookies; // Cookies parsed from set-cookie
|
|
340
|
+
|
|
341
|
+
yield* response.text; // Effect<string, HttpClientError>
|
|
342
|
+
yield* response.json; // Effect<Schema.Json, HttpClientError> — '' parses to null
|
|
343
|
+
yield* response.arrayBuffer; // Effect<ArrayBuffer, HttpClientError>
|
|
344
|
+
yield* response.formData; // Effect<FormData, HttpClientError>
|
|
345
|
+
yield* response.urlParamsBody; // Effect<UrlParams, HttpClientError> (urlencoded bodies)
|
|
346
|
+
response.stream; // Stream<Uint8Array, HttpClientError>
|
|
347
|
+
});
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`text`, `arrayBuffer`, and `formData` are cached — reading twice is safe. `stream` is **not**: it consumes the underlying body, so do not mix `stream` with the other accessors on the same response.
|
|
351
|
+
|
|
352
|
+
### Schema-validated bodies
|
|
353
|
+
|
|
354
|
+
Status classification comes first. Configure `HttpClient.filterStatusOk`, apply `HttpClientResponse.filterStatusOk`, or use `matchStatus` before selecting the success-body schema.
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
class Todo extends Schema.Class<Todo>('Todo')({
|
|
358
|
+
userId: Schema.Number,
|
|
359
|
+
id: Schema.Number,
|
|
360
|
+
title: Schema.String,
|
|
361
|
+
completed: Schema.Boolean
|
|
362
|
+
}) {}
|
|
363
|
+
|
|
364
|
+
class ResponseHeaders extends Schema.Class<ResponseHeaders>('ResponseHeaders')({
|
|
365
|
+
'x-request-id': Schema.String
|
|
366
|
+
}) {}
|
|
367
|
+
|
|
368
|
+
class TodoResponse extends Schema.Class<TodoResponse>('TodoResponse')({
|
|
369
|
+
status: Schema.Literal(200),
|
|
370
|
+
body: Todo
|
|
371
|
+
}) {}
|
|
372
|
+
|
|
373
|
+
class NoContentResponse extends Schema.Class<NoContentResponse>('NoContentResponse')({
|
|
374
|
+
status: Schema.Literal(204)
|
|
375
|
+
}) {}
|
|
376
|
+
|
|
377
|
+
// Factory: pass the schema once, reuse the decoder.
|
|
378
|
+
// Effect<Todo, HttpClientError | Schema.SchemaError, DecodingServices>
|
|
379
|
+
const todo = client.get('/todos/1').pipe(
|
|
380
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
381
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Other decoders:
|
|
385
|
+
HttpClientResponse.schemaBodyUrlParams(MyFormSchema); // urlencoded body
|
|
386
|
+
HttpClientResponse.schemaHeaders(ResponseHeaders);
|
|
387
|
+
HttpClientResponse.schemaJson(TodoResponse); // { status, headers, body }
|
|
388
|
+
HttpClientResponse.schemaNoBody(NoContentResponse); // { status, headers } — no body read
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`schemaBodyJson` and `schemaJson` accept schema parse options plus `{ reviver }`. The reviver runs during JSON parsing and may produce arbitrary values; the supplied schema still validates the revived result.
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
const revived = yield* client.get('/todos/1').pipe(
|
|
395
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
396
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo, {
|
|
397
|
+
reviver: (key, value) => key === 'title' ? 'revived title' : value
|
|
398
|
+
}))
|
|
399
|
+
);
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Pattern matching on status
|
|
403
|
+
|
|
404
|
+
`matchStatus` checks exact status keys first, then class keys (`'2xx'`–`'5xx'`), then `orElse`:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
const result = yield* client.get(`/users/${id}`).pipe(
|
|
408
|
+
Effect.flatMap(
|
|
409
|
+
HttpClientResponse.matchStatus({
|
|
410
|
+
200: HttpClientResponse.schemaBodyJson(User),
|
|
411
|
+
404: () => Effect.succeed(null),
|
|
412
|
+
'5xx': (response) => Effect.fail(new ServerDown({ status: response.status })),
|
|
413
|
+
orElse: (response) => Effect.fail(new Unexpected({ status: response.status }))
|
|
414
|
+
})
|
|
415
|
+
)
|
|
416
|
+
);
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Response streaming
|
|
420
|
+
|
|
421
|
+
`HttpClientResponse.stream` flattens an `Effect<HttpClientResponse>` into the body stream:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
const lines = HttpClientResponse.stream(client.get('/logs')).pipe(
|
|
425
|
+
Stream.decodeText, // handles multi-byte chars across chunk boundaries
|
|
426
|
+
Stream.splitLines
|
|
427
|
+
);
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 5. Status Filtering and the Error Taxonomy
|
|
433
|
+
|
|
434
|
+
Every client failure is a single tagged error, `HttpClientError`, wrapping a `reason` union:
|
|
435
|
+
|
|
436
|
+
| `reason._tag` | When | Has `response`? |
|
|
437
|
+
|---|---|---|
|
|
438
|
+
| `TransportError` | network/connection failure while sending | no |
|
|
439
|
+
| `EncodeError` | request body encoding failed in transit | no |
|
|
440
|
+
| `InvalidUrlError` | URL could not be constructed | no |
|
|
441
|
+
| `StatusCodeError` | rejected by `filterStatus*` | yes |
|
|
442
|
+
| `DecodeError` | body reading/parsing failed | yes |
|
|
443
|
+
| `EmptyBodyError` | body expected but missing (e.g. `.stream` on a null body) | yes |
|
|
444
|
+
|
|
445
|
+
`error.request` always works; `error.response` is the response when the reason carries one. For wire transfer there is `HttpClientError.HttpClientErrorSchema` (`fromHttpClientError`).
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
const handled = program.pipe(
|
|
449
|
+
Effect.catchTag('HttpClientError', (error) => {
|
|
450
|
+
switch (error.reason._tag) {
|
|
451
|
+
case 'StatusCodeError':
|
|
452
|
+
return Effect.succeed(`got status ${error.reason.response.status}`);
|
|
453
|
+
case 'TransportError':
|
|
454
|
+
return Effect.fail(new NetworkDown({ cause: error.reason.cause }));
|
|
455
|
+
default:
|
|
456
|
+
return Effect.die(error);
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
);
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### Turning bad statuses into errors
|
|
463
|
+
|
|
464
|
+
By default any status — including 404 and 500 — is a **success** with that status code. Opt in to failure:
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
// Client-level (preferred): all requests through this client fail on non-2xx
|
|
468
|
+
const okClient = client.pipe(HttpClient.filterStatusOk);
|
|
469
|
+
|
|
470
|
+
// Custom predicate
|
|
471
|
+
const strict = client.pipe(HttpClient.filterStatus((status) => status === 200));
|
|
472
|
+
|
|
473
|
+
// Response-level, one-off
|
|
474
|
+
yield* client.get('/todos/1').pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Related client-level filters: `HttpClient.filterOrElse(predicate, orElse)` and `HttpClient.filterOrFail(predicate, orFailWith)` operate on the response value.
|
|
478
|
+
|
|
479
|
+
### Client-level error recovery
|
|
480
|
+
|
|
481
|
+
These transform the *client*, so the recovery applies to every request made with it:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
// Recover — pick one; after catch the error channel is never, so a following
|
|
485
|
+
// catchTag/tapError in the same pipe no longer typechecks
|
|
486
|
+
client.pipe(HttpClient.catch((error) => Effect.succeed(cachedResponse))); // all errors
|
|
487
|
+
client.pipe(HttpClient.catchTag('HttpClientError', (error) => fallback(error)));
|
|
488
|
+
client.pipe(HttpClient.catchTags({ HttpClientError: (error) => fallback(error) }));
|
|
489
|
+
|
|
490
|
+
// Observe — taps leave the error channel untouched
|
|
491
|
+
client.pipe(
|
|
492
|
+
HttpClient.tap((response) => Effect.log(`<- ${response.status}`)),
|
|
493
|
+
HttpClient.tapError((error) => Effect.logWarning(error.message)),
|
|
494
|
+
HttpClient.tapRequest((request) => Effect.log(`-> ${request.method} ${request.url}`))
|
|
495
|
+
);
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
## 6. Client Transformation Combinators
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
// Modify every outgoing request (appended AFTER existing preprocessing)
|
|
504
|
+
const apiClient = client.pipe(
|
|
505
|
+
HttpClient.mapRequest((request) =>
|
|
506
|
+
request.pipe(
|
|
507
|
+
HttpClientRequest.prependUrl('https://api.example.com'),
|
|
508
|
+
HttpClientRequest.bearerToken(token),
|
|
509
|
+
HttpClientRequest.acceptJson
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// Effectful request transformation (e.g. fetch a fresh token)
|
|
515
|
+
client.pipe(
|
|
516
|
+
HttpClient.mapRequestEffect((request) =>
|
|
517
|
+
Effect.map(TokenService.current, (token) => HttpClientRequest.bearerToken(request, token))
|
|
518
|
+
)
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// Prepended variants run BEFORE existing preprocessing — use when a transform
|
|
522
|
+
// must see the caller's original request (mapRequest sees prior transforms' output)
|
|
523
|
+
HttpClient.mapRequestInput(f);
|
|
524
|
+
HttpClient.mapRequestInputEffect(f);
|
|
525
|
+
|
|
526
|
+
// Wrap the response effect itself — full power (retry, timeout, services, logging)
|
|
527
|
+
client.pipe(
|
|
528
|
+
HttpClient.transformResponse(Effect.timeout('10 seconds')),
|
|
529
|
+
HttpClient.transform((effect, request) =>
|
|
530
|
+
request.method === 'GET' ? Effect.retry(effect, Schedule.recurs(2)) : effect
|
|
531
|
+
)
|
|
532
|
+
);
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Order matters and reads inside-out: combinators wrap the existing `postprocess`, so in `client.pipe(HttpClient.filterStatusOk, HttpClient.retryTransient({...}))` the retry sees the `StatusCodeError`s produced by the filter.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## 7. Resilience: Retries, Timeouts, Rate Limiting
|
|
540
|
+
|
|
541
|
+
### `HttpClient.retry`
|
|
542
|
+
|
|
543
|
+
Retries are an operation policy, not a harmless client default. Retry only when the operation is proven idempotent: safe reads, an idempotent method with provider guarantees, or a write protected by a provider-supported idempotency key. Never put automatic retry on a shared client that also executes ordinary POST/PATCH operations.
|
|
544
|
+
|
|
545
|
+
Same option shape as `Effect.retry` — a `Schedule` or an options bag (`times`, `schedule`, `while`, `until`):
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
idempotentClient.pipe(
|
|
549
|
+
HttpClient.retry(
|
|
550
|
+
Schedule.exponential('100 millis').pipe(Schedule.upTo({ times: 3 }))
|
|
551
|
+
)
|
|
552
|
+
);
|
|
553
|
+
idempotentClient.pipe(HttpClient.retry({ times: 3 }));
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### `HttpClient.retryTransient`
|
|
557
|
+
|
|
558
|
+
Purpose-built for HTTP. Transient = response status in {408, 429, 500, 502, 503, 504}, `TransportError`, a `StatusCodeError` wrapping a transient status, or a `TimeoutError` (so `Effect.timeout` composes with it).
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
client.pipe(
|
|
562
|
+
HttpClient.retryTransient({
|
|
563
|
+
retryOn: 'errors-and-responses', // default; or 'errors-only' | 'response-only'
|
|
564
|
+
schedule: Schedule.exponential('100 millis'), // optional
|
|
565
|
+
times: 3, // optional cap
|
|
566
|
+
while: (error) => isAlsoTransient(error) // optional extra error predicate
|
|
567
|
+
})
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
// Schedule-only shorthand (equivalent to retryOn: 'errors-and-responses').
|
|
571
|
+
// Data-first only — the data-last bare-schedule overload fails to infer E;
|
|
572
|
+
// inside .pipe use the options bag with `schedule` instead.
|
|
573
|
+
const retried = HttpClient.retryTransient(
|
|
574
|
+
idempotentClient,
|
|
575
|
+
Schedule.spaced('1 second').pipe(Schedule.upTo({ times: 3 }))
|
|
576
|
+
);
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
`retryOn: 'errors-and-responses'` retries transient **successful responses** (e.g. a raw 503 with no `filterStatusOk`) as well as transient errors. `'errors-only'` ignores transient response statuses unless something (like `filterStatusOk`) has converted them to errors first. The `while` predicate is ignored in `'response-only'` mode.
|
|
580
|
+
|
|
581
|
+
Bound every retry policy and keep exhaustion visible. After the retry combinator, preserve the terminal typed error and emit one redacted log/metric/span annotation with operation name, attempts, status/provider code when available, and provider request ID. Do not recover exhaustion to an empty/default success or log secrets/full response bodies.
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
const idempotentReads = client.pipe(
|
|
585
|
+
HttpClient.filterStatusOk,
|
|
586
|
+
HttpClient.retryTransient({
|
|
587
|
+
schedule: Schedule.exponential('100 millis'),
|
|
588
|
+
times: 3
|
|
589
|
+
}),
|
|
590
|
+
HttpClient.tapError((error) =>
|
|
591
|
+
Effect.logError('provider request exhausted retries').pipe(
|
|
592
|
+
Effect.annotateLogs({ operation: 'Todos.getTodo', errorTag: error._tag })
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
);
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Timeouts
|
|
599
|
+
|
|
600
|
+
There is no client-specific timeout combinator — use `Effect.timeout`:
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
// Per request
|
|
604
|
+
yield* client.get('/slow').pipe(Effect.timeout('5 seconds'));
|
|
605
|
+
|
|
606
|
+
// Baked into the client
|
|
607
|
+
const bounded = client.pipe(HttpClient.transformResponse(Effect.timeout('10 seconds')));
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
The undici transport neutralizes undici's own timeouts (`headersTimeout` one hour, `bodyTimeout` disabled) so `Effect.timeout` is the practical source of truth. Interruption (including timeout) aborts the in-flight request via `AbortController`.
|
|
611
|
+
|
|
612
|
+
### `HttpClient.withRateLimiter`
|
|
613
|
+
|
|
614
|
+
Client-side rate limiting backed by the `RateLimiter` service from `effect/unstable/persistence`. It delays requests past the limit, **automatically retries 429s** (responses or `StatusCodeError`s) back through the limiter honoring `retry-after`, and by default updates its limit/window from `ratelimit-*` / `x-ratelimit-*` response headers.
|
|
615
|
+
|
|
616
|
+
Because `withRateLimiter` retries 429 responses independently of the HTTP method, apply it with `times > 0` only to a client restricted to proven-idempotent operations. For a mixed or non-idempotent client, set `times: 0` and handle the returned 429 as a visible typed failure.
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
import { RateLimiter } from 'effect/unstable/persistence';
|
|
620
|
+
|
|
621
|
+
const limitedReads = Effect.gen(function* () {
|
|
622
|
+
const limiter = yield* RateLimiter.RateLimiter;
|
|
623
|
+
return (yield* HttpClient.HttpClient).pipe(
|
|
624
|
+
HttpClient.withRateLimiter({
|
|
625
|
+
limiter,
|
|
626
|
+
key: 'github', // or (request) => string for per-endpoint limits
|
|
627
|
+
limit: 100, // initial requests per window
|
|
628
|
+
window: '1 minute',
|
|
629
|
+
algorithm: 'fixed-window', // default; or 'token-bucket'
|
|
630
|
+
tokens: 1, // default; or (request) => number
|
|
631
|
+
disableResponseInspection: false, // default: learn limits from headers
|
|
632
|
+
times: 5, // bounds automatic 429 retries; default is unbounded
|
|
633
|
+
responseHeaders: {
|
|
634
|
+
limit: 'x-custom-limit',
|
|
635
|
+
remaining: 'x-custom-remaining',
|
|
636
|
+
reset: 'x-custom-reset',
|
|
637
|
+
retryAfter: 'x-custom-retry-after'
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const RateLimiterLayer = RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory));
|
|
644
|
+
// Redis-backed store for multi-process limits: RateLimiter.layerStoreRedis
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
Error channel gains `RateLimiter.RateLimiterError`.
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
## 8. Cookies, Redirects, Tracing
|
|
652
|
+
|
|
653
|
+
### Cookie jar
|
|
654
|
+
|
|
655
|
+
```ts
|
|
656
|
+
const withSession = Effect.gen(function* () {
|
|
657
|
+
const jar = yield* Ref.make(Cookies.empty);
|
|
658
|
+
const client = (yield* HttpClient.HttpClient).pipe(HttpClient.withCookiesRef(jar));
|
|
659
|
+
|
|
660
|
+
yield* client.post('https://example.com/login', {
|
|
661
|
+
body: HttpBody.urlParams(UrlParams.fromInput({ user: 'u', pass: 'p' }))
|
|
662
|
+
});
|
|
663
|
+
// set-cookie values from every response are merged into the jar and sent
|
|
664
|
+
// as a `cookie` header on subsequent requests
|
|
665
|
+
const profile = yield* client.get('https://example.com/me');
|
|
666
|
+
|
|
667
|
+
const session = Cookies.getValue(yield* Ref.get(jar), 'session'); // Option<string>
|
|
668
|
+
});
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
Individual responses also expose `response.cookies` (parsed `set-cookie`); useful helpers: `Cookies.toCookieHeader`, `Cookies.fromSetCookie`, `Cookies.merge`, `Cookies.toRecord`.
|
|
672
|
+
|
|
673
|
+
### Redirects
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
const redirecting = client.pipe(HttpClient.followRedirects(5)); // default max 10
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
`followRedirects` re-issues the request when status is 3xx and a `location` header is present. Note: the fetch transport delegates to `fetch`, which already follows redirects by default — `followRedirects` matters mainly for `NodeHttpClient.layerUndici` / `layerNodeHttp`, which do not follow. To take manual control under fetch, provide `FetchHttpClient.RequestInit` with `{ redirect: 'manual' }`.
|
|
680
|
+
|
|
681
|
+
### Tracing
|
|
682
|
+
|
|
683
|
+
Every request runs in a client span (default name `http.client {METHOD}`) with OTel-style attributes (`http.request.method`, `url.full`, `http.response.status_code`, ...), and the trace context is propagated via headers. Span request/response header attributes are redacted per `Headers.CurrentRedactedNames` (default: `authorization`, `cookie`, `set-cookie`, `x-api-key`). Control via `Context.Reference`s on `HttpClient`:
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
// Stop propagating traceparent headers to a third party (real usage: OtlpExporter)
|
|
687
|
+
const noPropagation = client.pipe(
|
|
688
|
+
HttpClient.transformResponse(Effect.provideService(HttpClient.TracerPropagationEnabled, false))
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
// Disable spans for matching requests (data-last; apply to any effect or via transformResponse)
|
|
692
|
+
program.pipe(
|
|
693
|
+
Effect.provideService(HttpClient.TracerDisabledWhen, (request) => request.url.includes('/health'))
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
// Custom span names
|
|
697
|
+
program.pipe(
|
|
698
|
+
Effect.provideService(HttpClient.SpanNameGenerator, (request) => `${request.method} ${request.url}`)
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
// Record only selected request/response headers as span attributes
|
|
702
|
+
program.pipe(
|
|
703
|
+
Effect.provideService(
|
|
704
|
+
HttpClient.TracerHeaderFilter,
|
|
705
|
+
(name, phase) => phase === 'response' && name === 'x-request-id'
|
|
706
|
+
)
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
// Replace the redacted-header list (Context.Reference; accepts strings or RegExps)
|
|
710
|
+
program.pipe(
|
|
711
|
+
Effect.provideService(Headers.CurrentRedactedNames, ['authorization', 'x-internal-token'])
|
|
712
|
+
);
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
---
|
|
716
|
+
|
|
717
|
+
## 9. Streaming, Aborts, and Connection Lifetime
|
|
718
|
+
|
|
719
|
+
### Streaming download to a file
|
|
720
|
+
|
|
721
|
+
```ts
|
|
722
|
+
import { FileSystem } from 'effect';
|
|
723
|
+
|
|
724
|
+
const download = Effect.gen(function* () {
|
|
725
|
+
const fs = yield* FileSystem.FileSystem;
|
|
726
|
+
const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
|
|
727
|
+
const response = yield* client.get('https://example.com/large.bin');
|
|
728
|
+
yield* response.stream.pipe(Stream.run(fs.sink('./large.bin')));
|
|
729
|
+
});
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Streaming upload
|
|
733
|
+
|
|
734
|
+
```ts
|
|
735
|
+
const upload = (data: Stream.Stream<Uint8Array, unknown>) =>
|
|
736
|
+
client.execute(
|
|
737
|
+
HttpClientRequest.post('https://api.example.com/ingest').pipe(
|
|
738
|
+
HttpClientRequest.bodyStream(data, { contentType: 'application/octet-stream' })
|
|
739
|
+
)
|
|
740
|
+
);
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### Abort semantics (important)
|
|
744
|
+
|
|
745
|
+
- Interrupting the request effect (timeout, race, scope close) aborts the in-flight request.
|
|
746
|
+
- Reading `response.stream` aborts the connection **when the stream ends** — including early termination via `Stream.take`. This is how partial downloads release the socket.
|
|
747
|
+
- If a response's body is never consumed, a `FinalizationRegistry` aborts the connection when the response is garbage collected (a 5s timer fallback where `FinalizationRegistry` is unavailable). Do not rely on this for timeliness — consume or scope the response.
|
|
748
|
+
- `HttpClient.withScope(client)` ties each request's lifetime to a `Scope` (adds `Scope` to `R`); the connection aborts when the scope closes:
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
const scoped = Effect.scoped(
|
|
752
|
+
Effect.gen(function* () {
|
|
753
|
+
const client = HttpClient.withScope(yield* HttpClient.HttpClient);
|
|
754
|
+
const response = yield* client.get('https://example.com/events');
|
|
755
|
+
yield* response.stream.pipe(Stream.decodeText, Stream.runForEach(handleChunk));
|
|
756
|
+
})
|
|
757
|
+
); // connection torn down here at the latest
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
## 10. Testing — Substituting the HttpClient
|
|
763
|
+
|
|
764
|
+
`HttpClient.make(f)` builds a full client from a request runner — perfect for mocks. `HttpClientResponse.fromWeb(request, new Response(...))` turns a Web `Response` into an `HttpClientResponse`:
|
|
765
|
+
|
|
766
|
+
```ts
|
|
767
|
+
// The runner also receives (url, signal, fiber) when you need them
|
|
768
|
+
const mockClient = HttpClient.make((request) =>
|
|
769
|
+
Effect.succeed(
|
|
770
|
+
HttpClientResponse.fromWeb(
|
|
771
|
+
request,
|
|
772
|
+
new Response(JSON.stringify({ id: 1, title: 'mock' }), {
|
|
773
|
+
status: 200,
|
|
774
|
+
headers: { 'content-type': 'application/json' }
|
|
775
|
+
})
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const TestHttpLayer = Layer.succeed(HttpClient.HttpClient, mockClient);
|
|
781
|
+
|
|
782
|
+
it.effect('decodes todos', () =>
|
|
783
|
+
Effect.gen(function* () {
|
|
784
|
+
const todo = yield* HttpClient.get('https://any/todos/1').pipe(
|
|
785
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
786
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
|
|
787
|
+
);
|
|
788
|
+
expect(todo.id).toBe(1);
|
|
789
|
+
}).pipe(Effect.provide(TestHttpLayer)));
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
Route by request to simulate failures and count attempts:
|
|
793
|
+
|
|
794
|
+
```ts
|
|
795
|
+
const makeFlaky = Effect.gen(function* () {
|
|
796
|
+
const attempts = yield* Ref.make(0);
|
|
797
|
+
const client = HttpClient.make((request) =>
|
|
798
|
+
Effect.gen(function* () {
|
|
799
|
+
const n = yield* Ref.updateAndGet(attempts, (n) => n + 1);
|
|
800
|
+
return HttpClientResponse.fromWeb(
|
|
801
|
+
request,
|
|
802
|
+
new Response(null, { status: n < 3 ? 503 : 200 })
|
|
803
|
+
);
|
|
804
|
+
})
|
|
805
|
+
);
|
|
806
|
+
return { attempts, client } as const;
|
|
807
|
+
});
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
The tag requires `E = HttpClientError` exactly, so derive mocks from `HttpClient.make` (which already has that type) rather than from clients whose error channel has been widened. (`HttpClient.makeWith(postprocess, preprocess)` builds a `HttpClient.With<E, R>` with custom error/context types, but only `With<HttpClientError, never>` can back the tag.) Alternatively, keep the real fetch transport and stub the fetch function itself: `Layer.succeed(FetchHttpClient.Fetch, mockFetchFn)` provided to `FetchHttpClient.layer`.
|
|
811
|
+
|
|
812
|
+
For declarative API clients derived from an `HttpApi` definition, see the effect-http-api skill; for serving HTTP, see the effect-http-server skill.
|
|
813
|
+
|
|
814
|
+
---
|
|
815
|
+
|
|
816
|
+
## Key Patterns
|
|
817
|
+
|
|
818
|
+
### Configured API client wrapped in a service
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
import { Context, Effect, flow, Layer, Schedule, Schema } from 'effect';
|
|
822
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http';
|
|
823
|
+
|
|
824
|
+
class Todo extends Schema.Class<Todo>('Todo')({
|
|
825
|
+
userId: Schema.Number,
|
|
826
|
+
id: Schema.Number,
|
|
827
|
+
title: Schema.String,
|
|
828
|
+
completed: Schema.Boolean
|
|
829
|
+
}) {}
|
|
830
|
+
|
|
831
|
+
class TodosError extends Schema.TaggedError<TodosError>()('TodosError', {
|
|
832
|
+
cause: Schema.Defect()
|
|
833
|
+
}) {}
|
|
834
|
+
|
|
835
|
+
export class Todos extends Context.Service<Todos, {
|
|
836
|
+
getTodo(id: number): Effect.Effect<Todo, TodosError>;
|
|
837
|
+
createTodo(todo: Omit<Todo, 'id'>): Effect.Effect<Todo, TodosError>;
|
|
838
|
+
}>()('app/Todos') {
|
|
839
|
+
static readonly layer: Layer.Layer<Todos, never, HttpClient.HttpClient> = Layer.effect(
|
|
840
|
+
Todos,
|
|
841
|
+
Effect.gen(function* () {
|
|
842
|
+
const client = (yield* HttpClient.HttpClient).pipe(
|
|
843
|
+
HttpClient.mapRequest(flow(
|
|
844
|
+
HttpClientRequest.prependUrl('https://jsonplaceholder.typicode.com'),
|
|
845
|
+
HttpClientRequest.acceptJson
|
|
846
|
+
)),
|
|
847
|
+
HttpClient.filterStatusOk
|
|
848
|
+
);
|
|
849
|
+
const idempotentReads = client.pipe(
|
|
850
|
+
HttpClient.retryTransient({
|
|
851
|
+
schedule: Schedule.exponential('100 millis'),
|
|
852
|
+
times: 3
|
|
853
|
+
}),
|
|
854
|
+
HttpClient.tapError((error) =>
|
|
855
|
+
Effect.logError('Todos request exhausted retries').pipe(
|
|
856
|
+
Effect.annotateLogs({ operation: 'Todos.getTodo', errorTag: error._tag })
|
|
857
|
+
)
|
|
858
|
+
)
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
const getTodo = Effect.fn('Todos.getTodo')(function* (id: number) {
|
|
862
|
+
return yield* idempotentReads.get(`/todos/${id}`).pipe(
|
|
863
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)),
|
|
864
|
+
Effect.mapError((cause) => new TodosError({ cause }))
|
|
865
|
+
);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
const createTodo = Effect.fn('Todos.createTodo')(function* (todo: Omit<Todo, 'id'>) {
|
|
869
|
+
return yield* HttpClientRequest.post('/todos').pipe(
|
|
870
|
+
HttpClientRequest.bodyJsonUnsafe(todo),
|
|
871
|
+
client.execute,
|
|
872
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)),
|
|
873
|
+
Effect.mapError((cause) => new TodosError({ cause }))
|
|
874
|
+
);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
return Todos.of({ getTodo, createTodo });
|
|
878
|
+
})
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
static readonly defaultLayer: Layer.Layer<Todos> = Todos.layer.pipe(
|
|
882
|
+
Layer.provide(FetchHttpClient.layer)
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
The shared base client performs status filtering but does not retry. Only `idempotentReads` retries; `createTodo` executes its POST once unless the provider contract is later strengthened with an idempotency key.
|
|
888
|
+
|
|
889
|
+
### Schema-encoded request, schema-decoded response
|
|
890
|
+
|
|
891
|
+
```ts
|
|
892
|
+
class CreateUser extends Schema.Class<CreateUser>('CreateUser')({
|
|
893
|
+
name: Schema.String,
|
|
894
|
+
email: Schema.String
|
|
895
|
+
}) {}
|
|
896
|
+
|
|
897
|
+
class User extends Schema.Class<User>('User')({
|
|
898
|
+
id: Schema.Number,
|
|
899
|
+
name: Schema.String,
|
|
900
|
+
email: Schema.String
|
|
901
|
+
}) {}
|
|
902
|
+
|
|
903
|
+
const createUser = (input: typeof CreateUser.Type) =>
|
|
904
|
+
HttpClientRequest.post('/users').pipe(
|
|
905
|
+
HttpClientRequest.schemaBodyJson(CreateUser)(input),
|
|
906
|
+
Effect.flatMap(client.execute),
|
|
907
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
908
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(User))
|
|
909
|
+
);
|
|
910
|
+
// Effect<User, HttpBodyError | HttpClientError | Schema.SchemaError> — R is never with a
|
|
911
|
+
// captured client; R = HttpClient.HttpClient only via accessors like HttpClient.execute
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### Status-driven control flow without filterStatusOk
|
|
915
|
+
|
|
916
|
+
```ts
|
|
917
|
+
const findUser = (id: string) =>
|
|
918
|
+
client.get(`/users/${id}`).pipe(
|
|
919
|
+
Effect.flatMap(
|
|
920
|
+
HttpClientResponse.matchStatus({
|
|
921
|
+
200: (r) => Effect.asSome(HttpClientResponse.schemaBodyJson(User)(r)),
|
|
922
|
+
404: () => Effect.succeedNone,
|
|
923
|
+
orElse: (r) =>
|
|
924
|
+
Effect.fail(
|
|
925
|
+
new HttpClientError.HttpClientError({
|
|
926
|
+
reason: new HttpClientError.StatusCodeError({
|
|
927
|
+
request: r.request,
|
|
928
|
+
response: r,
|
|
929
|
+
description: 'unexpected status'
|
|
930
|
+
})
|
|
931
|
+
})
|
|
932
|
+
)
|
|
933
|
+
})
|
|
934
|
+
)
|
|
935
|
+
);
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
(`orElse` manufactures the standard `StatusCodeError` so the error channel stays `HttpClientError`.)
|
|
939
|
+
|
|
940
|
+
### Rate-limited, traced, resilient idempotent third-party client
|
|
941
|
+
|
|
942
|
+
```ts
|
|
943
|
+
const makeGithubReads = Effect.gen(function* () {
|
|
944
|
+
const limiter = yield* RateLimiter.RateLimiter;
|
|
945
|
+
return (yield* HttpClient.HttpClient).pipe(
|
|
946
|
+
HttpClient.mapRequest(flow(
|
|
947
|
+
HttpClientRequest.prependUrl('https://api.github.com'),
|
|
948
|
+
HttpClientRequest.bearerToken(token),
|
|
949
|
+
HttpClientRequest.setHeader('x-github-api-version', '2022-11-28')
|
|
950
|
+
)),
|
|
951
|
+
HttpClient.filterStatusOk,
|
|
952
|
+
HttpClient.withRateLimiter({
|
|
953
|
+
limiter,
|
|
954
|
+
key: 'github-reads',
|
|
955
|
+
limit: 5000,
|
|
956
|
+
window: '1 hour',
|
|
957
|
+
times: 3
|
|
958
|
+
}),
|
|
959
|
+
HttpClient.retryTransient({ schedule: Schedule.exponential('250 millis'), times: 3 }),
|
|
960
|
+
HttpClient.transformResponse(Effect.timeout('30 seconds')),
|
|
961
|
+
HttpClient.tapError((error) =>
|
|
962
|
+
Effect.logError('GitHub read exhausted retries').pipe(
|
|
963
|
+
Effect.annotateLogs({ operation: 'Github.read', errorTag: error._tag })
|
|
964
|
+
)
|
|
965
|
+
)
|
|
966
|
+
);
|
|
967
|
+
});
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
## Common Mistakes
|
|
971
|
+
|
|
972
|
+
1. **v3 imports** — `@effect/platform/HttpClient` and friends no longer exist. Import `HttpClient`, `HttpClientRequest`, `HttpClientResponse`, `FetchHttpClient`, etc. from `effect/unstable/http`; only `NodeHttpClient` comes from `@effect/platform-node`.
|
|
973
|
+
2. **Calling body accessors as methods** — `response.text`, `response.json`, `response.stream` are property getters returning Effects/Streams. `yield* response.json`, not `await response.json()`.
|
|
974
|
+
3. **`HttpClientRequest.del` does not exist** — the request constructor is `HttpClientRequest.delete`; the client/service method and accessor are `client.del` / `HttpClient.del`.
|
|
975
|
+
4. **Catching v3 error tags** — there are no top-level `RequestError`/`ResponseError` tags anymore. Everything is one tag, `HttpClientError`; branch on `error.reason._tag` (`TransportError`, `StatusCodeError`, `DecodeError`, ...).
|
|
976
|
+
5. **Treating `bodyJson` as synchronous** — `HttpClientRequest.bodyJson(value)` returns `Effect<HttpClientRequest, HttpBodyError>`; `Effect.flatMap(client.execute)` it. Use `bodyJsonUnsafe` only when serialization cannot fail (it throws, it does not fail the Effect).
|
|
977
|
+
6. **Forgetting `filterStatusOk`** — a 404 or 500 is a *successful* response by default. Add `HttpClient.filterStatusOk` (or response-level `HttpClientResponse.filterStatusOk` / `matchStatus`) before decoding bodies.
|
|
978
|
+
7. **`retryTransient({ mode: ... })` / `retryOn: 'both'`** — the option key is `retryOn` with exactly `'errors-only' | 'response-only' | 'errors-and-responses'` (default `'errors-and-responses'`). With `'errors-only'`, a raw 503 success response is not retried unless `filterStatusOk` was applied first.
|
|
979
|
+
8. **Wrong combinator order** — combinators wrap the client built so far, so apply `filterStatusOk` *before* (i.e. earlier in the pipe than) `retryTransient` so it sees the `StatusCodeError`s. Reversed, the retry only sees raw responses (covered by the default `retryOn`, but invisible with `'errors-only'`). `withRateLimiter` works in either order — it inspects both raw 429 responses and 429 `StatusCodeError`s.
|
|
980
|
+
9. **Mixing `.stream` with `.text`/`.json` on one response** — `text`/`arrayBuffer`/`formData` are cached, but `.stream` consumes the raw body exactly once; pick one strategy per response.
|
|
981
|
+
10. **Schema decoder factories** — `HttpClientResponse.schemaBodyJson(S)` and `HttpClientRequest.schemaBodyJson(S)` take the schema first and return a function; do not pass the response/body in the same call as the schema.
|
|
982
|
+
11. **Per-chunk `TextDecoder` on `response.stream`** — corrupts multi-byte characters split across chunks; use `Stream.decodeText`.
|
|
983
|
+
12. **Expecting `followRedirects` to change fetch behavior** — `fetch` already follows redirects internally, so under `FetchHttpClient` the client never sees the 3xx; `followRedirects` is for the Node undici/node:http transports (which do not follow).
|
|
984
|
+
13. **Looking for a `timeout` option on the client or transports** — there is none, and the undici transport neutralizes undici's own timeouts on purpose (`headersTimeout` one hour, `bodyTimeout` off). Use `Effect.timeout` per request or `HttpClient.transformResponse(Effect.timeout(...))` client-wide. The resulting `TimeoutError` counts as transient for `retryTransient`.
|
|
985
|
+
14. **`urlParams` as a pre-built query string** — pass structured input (`{ page: 1, tags: ['a', 'b'] }`); values are coerced, `undefined` entries dropped, arrays repeated, nested records bracketed. Don't hand-encode into the URL.
|
|
986
|
+
15. **Assuming an empty body fails `response.json`** — an empty body decodes to `null`, not an error; `response.stream` on a bodiless response fails with reason `EmptyBodyError`.
|
|
987
|
+
16. **Raw `fetch` in application/provider code** — use `HttpClient`. Raw fetch belongs only in a named low-level transport adapter with a documented necessity, interruption wiring, typed errors, status-first handling, and schema decoding.
|
|
988
|
+
17. **Retrying a mixed client** — client combinators affect every method. Split idempotent operations onto a retrying client; leave non-idempotent POST/PATCH calls unretried unless protected by a provider-supported idempotency guarantee.
|
|
989
|
+
18. **Hiding retry exhaustion** — bound attempts, preserve the final typed failure, and record redacted evidence once after retries are exhausted.
|