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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/guidance/effect-first-development.md +1247 -0
  4. package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
  5. package/guidance/post__parse-dont-validate.md +109 -0
  6. package/guidance/progressive-disclosure-guidance.md +38 -0
  7. package/package.json +63 -0
  8. package/patterns/avoid-any.md +37 -0
  9. package/patterns/avoid-data-tagged-error.md +34 -0
  10. package/patterns/avoid-direct-json.md +51 -0
  11. package/patterns/avoid-direct-tag-checks.md +54 -0
  12. package/patterns/avoid-expect-in-if.md +52 -0
  13. package/patterns/avoid-mutable-state.md +70 -0
  14. package/patterns/avoid-native-fetch.md +61 -0
  15. package/patterns/avoid-node-imports.md +86 -0
  16. package/patterns/avoid-non-null-assertion.md +44 -0
  17. package/patterns/avoid-object-type.md +46 -0
  18. package/patterns/avoid-option-getorthrow.md +39 -0
  19. package/patterns/avoid-platform-coupling.md +43 -0
  20. package/patterns/avoid-process-env.md +43 -0
  21. package/patterns/avoid-react-hooks.md +73 -0
  22. package/patterns/avoid-schema-suffix.md +45 -0
  23. package/patterns/avoid-sync-fs.md +68 -0
  24. package/patterns/avoid-try-catch.md +47 -0
  25. package/patterns/avoid-ts-ignore.md +38 -0
  26. package/patterns/avoid-untagged-errors.md +67 -0
  27. package/patterns/avoid-yield-ref.md +46 -0
  28. package/patterns/casting-awareness.md +46 -0
  29. package/patterns/context-tag-extends.md +84 -0
  30. package/patterns/effect-catchall-default.md +61 -0
  31. package/patterns/effect-promise-vs-trypromise.md +47 -0
  32. package/patterns/effect-run-in-body.md +58 -0
  33. package/patterns/imperative-loops.md +76 -0
  34. package/patterns/prefer-arr-sort.md +52 -0
  35. package/patterns/prefer-duration-values.md +56 -0
  36. package/patterns/prefer-effect-fn.md +161 -0
  37. package/patterns/prefer-match-over-switch.md +48 -0
  38. package/patterns/prefer-option-over-null.md +56 -0
  39. package/patterns/prefer-redacted-config.md +70 -0
  40. package/patterns/prefer-schema-class.md +54 -0
  41. package/patterns/require-effect-concurrency.md +83 -0
  42. package/patterns/stream-large-files.md +63 -0
  43. package/patterns/throw-in-effect-gen.md +62 -0
  44. package/patterns/use-clock-service.md +45 -0
  45. package/patterns/use-command-executor-service.md +54 -0
  46. package/patterns/use-console-service.md +54 -0
  47. package/patterns/use-filesystem-service.md +59 -0
  48. package/patterns/use-http-client-service.md +77 -0
  49. package/patterns/use-path-service.md +53 -0
  50. package/patterns/use-random-service.md +45 -0
  51. package/patterns/use-temp-file-scoped.md +66 -0
  52. package/patterns/vm-in-wrong-file.md +51 -0
  53. package/patterns/yield-in-for-loop.md +61 -0
  54. package/skills/effect-ai-chat/SKILL.md +472 -0
  55. package/skills/effect-ai-language-model/SKILL.md +652 -0
  56. package/skills/effect-ai-prompt/SKILL.md +752 -0
  57. package/skills/effect-ai-provider/SKILL.md +668 -0
  58. package/skills/effect-ai-streaming/SKILL.md +418 -0
  59. package/skills/effect-ai-tool/SKILL.md +1132 -0
  60. package/skills/effect-atom-rpc/SKILL.md +488 -0
  61. package/skills/effect-atom-state/SKILL.md +640 -0
  62. package/skills/effect-batching/SKILL.md +614 -0
  63. package/skills/effect-cache/SKILL.md +570 -0
  64. package/skills/effect-cli/SKILL.md +523 -0
  65. package/skills/effect-command-executor/SKILL.md +675 -0
  66. package/skills/effect-concurrency-testing/SKILL.md +612 -0
  67. package/skills/effect-config/SKILL.md +580 -0
  68. package/skills/effect-context-witness/SKILL.md +274 -0
  69. package/skills/effect-domain-modeling/SKILL.md +1212 -0
  70. package/skills/effect-domain-predicates/SKILL.md +867 -0
  71. package/skills/effect-error-handling/SKILL.md +1581 -0
  72. package/skills/effect-fiber/SKILL.md +731 -0
  73. package/skills/effect-filesystem/SKILL.md +624 -0
  74. package/skills/effect-graph/SKILL.md +571 -0
  75. package/skills/effect-http-api/SKILL.md +1760 -0
  76. package/skills/effect-http-client/SKILL.md +989 -0
  77. package/skills/effect-http-server/SKILL.md +920 -0
  78. package/skills/effect-incremental-migration/SKILL.md +362 -0
  79. package/skills/effect-layer-design/SKILL.md +642 -0
  80. package/skills/effect-managed-runtime/SKILL.md +395 -0
  81. package/skills/effect-mcp-server/SKILL.md +608 -0
  82. package/skills/effect-observability/SKILL.md +719 -0
  83. package/skills/effect-optics/SKILL.md +554 -0
  84. package/skills/effect-parallelization/SKILL.md +668 -0
  85. package/skills/effect-path/SKILL.md +296 -0
  86. package/skills/effect-pattern-matching/SKILL.md +914 -0
  87. package/skills/effect-platform-abstraction/SKILL.md +1175 -0
  88. package/skills/effect-platform-layers/SKILL.md +514 -0
  89. package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
  90. package/skills/effect-react-composition/SKILL.md +986 -0
  91. package/skills/effect-react-vm/SKILL.md +675 -0
  92. package/skills/effect-rpc-api/SKILL.md +624 -0
  93. package/skills/effect-rpc-client/SKILL.md +666 -0
  94. package/skills/effect-rpc-cluster/SKILL.md +1623 -0
  95. package/skills/effect-rpc-server/SKILL.md +767 -0
  96. package/skills/effect-scheduling/SKILL.md +124 -0
  97. package/skills/effect-schema-composition/SKILL.md +975 -0
  98. package/skills/effect-schema-v4/SKILL.md +691 -0
  99. package/skills/effect-scope/SKILL.md +682 -0
  100. package/skills/effect-service-implementation/SKILL.md +656 -0
  101. package/skills/effect-socket/SKILL.md +703 -0
  102. package/skills/effect-sql/SKILL.md +781 -0
  103. package/skills/effect-stream/SKILL.md +765 -0
  104. package/skills/effect-testing/SKILL.md +1331 -0
  105. package/skills/effect-typeclass-design/SKILL.md +161 -0
  106. package/skills/effect-wide-events/Article.md +66 -0
  107. package/skills/effect-wide-events/SKILL.md +95 -0
  108. package/skills/effect-workflow/SKILL.md +810 -0
  109. package/src/agent-policy.ts +22 -0
  110. package/src/enforcer.ts +104 -0
  111. package/src/frontmatter.ts +34 -0
  112. package/src/guidance.ts +66 -0
  113. package/src/index.ts +38 -0
  114. package/src/pattern-catalog.ts +115 -0
  115. package/src/pattern-matcher.ts +178 -0
  116. package/src/pattern.ts +97 -0
  117. package/src/skills.ts +29 -0
  118. 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.