@dojocho/effect-ts 0.0.1
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/DOJO.md +22 -0
- package/dojo.json +50 -0
- package/katas/001-hello-effect/SENSEI.md +72 -0
- package/katas/001-hello-effect/solution.test.ts +35 -0
- package/katas/001-hello-effect/solution.ts +16 -0
- package/katas/002-transform-with-map/SENSEI.md +72 -0
- package/katas/002-transform-with-map/solution.test.ts +33 -0
- package/katas/002-transform-with-map/solution.ts +16 -0
- package/katas/003-generator-pipelines/SENSEI.md +72 -0
- package/katas/003-generator-pipelines/solution.test.ts +40 -0
- package/katas/003-generator-pipelines/solution.ts +29 -0
- package/katas/004-flatmap-and-chaining/SENSEI.md +80 -0
- package/katas/004-flatmap-and-chaining/solution.test.ts +34 -0
- package/katas/004-flatmap-and-chaining/solution.ts +18 -0
- package/katas/005-pipe-composition/SENSEI.md +81 -0
- package/katas/005-pipe-composition/solution.test.ts +41 -0
- package/katas/005-pipe-composition/solution.ts +19 -0
- package/katas/006-handle-errors/SENSEI.md +86 -0
- package/katas/006-handle-errors/solution.test.ts +53 -0
- package/katas/006-handle-errors/solution.ts +30 -0
- package/katas/007-tagged-errors/SENSEI.md +79 -0
- package/katas/007-tagged-errors/solution.test.ts +82 -0
- package/katas/007-tagged-errors/solution.ts +37 -0
- package/katas/008-error-patterns/SENSEI.md +89 -0
- package/katas/008-error-patterns/solution.test.ts +41 -0
- package/katas/008-error-patterns/solution.ts +38 -0
- package/katas/009-option-type/SENSEI.md +96 -0
- package/katas/009-option-type/solution.test.ts +49 -0
- package/katas/009-option-type/solution.ts +26 -0
- package/katas/010-either-and-exit/SENSEI.md +86 -0
- package/katas/010-either-and-exit/solution.test.ts +33 -0
- package/katas/010-either-and-exit/solution.ts +17 -0
- package/katas/011-services-and-context/SENSEI.md +82 -0
- package/katas/011-services-and-context/solution.test.ts +23 -0
- package/katas/011-services-and-context/solution.ts +17 -0
- package/katas/012-layers/SENSEI.md +73 -0
- package/katas/012-layers/solution.test.ts +23 -0
- package/katas/012-layers/solution.ts +26 -0
- package/katas/013-testing-effects/SENSEI.md +88 -0
- package/katas/013-testing-effects/solution.test.ts +41 -0
- package/katas/013-testing-effects/solution.ts +20 -0
- package/katas/014-schema-basics/SENSEI.md +81 -0
- package/katas/014-schema-basics/solution.test.ts +35 -0
- package/katas/014-schema-basics/solution.ts +25 -0
- package/katas/015-domain-modeling/SENSEI.md +85 -0
- package/katas/015-domain-modeling/solution.test.ts +46 -0
- package/katas/015-domain-modeling/solution.ts +42 -0
- package/katas/016-retry-and-schedule/SENSEI.md +72 -0
- package/katas/016-retry-and-schedule/solution.test.ts +26 -0
- package/katas/016-retry-and-schedule/solution.ts +23 -0
- package/katas/017-parallel-effects/SENSEI.md +70 -0
- package/katas/017-parallel-effects/solution.test.ts +33 -0
- package/katas/017-parallel-effects/solution.ts +17 -0
- package/katas/018-race-and-timeout/SENSEI.md +75 -0
- package/katas/018-race-and-timeout/solution.test.ts +30 -0
- package/katas/018-race-and-timeout/solution.ts +27 -0
- package/katas/019-ref-and-state/SENSEI.md +72 -0
- package/katas/019-ref-and-state/solution.test.ts +29 -0
- package/katas/019-ref-and-state/solution.ts +16 -0
- package/katas/020-fibers/SENSEI.md +80 -0
- package/katas/020-fibers/solution.test.ts +23 -0
- package/katas/020-fibers/solution.ts +23 -0
- package/katas/021-acquire-release/SENSEI.md +57 -0
- package/katas/021-acquire-release/solution.test.ts +23 -0
- package/katas/021-acquire-release/solution.ts +22 -0
- package/katas/022-scoped-layers/SENSEI.md +52 -0
- package/katas/022-scoped-layers/solution.test.ts +35 -0
- package/katas/022-scoped-layers/solution.ts +19 -0
- package/katas/023-resource-patterns/SENSEI.md +52 -0
- package/katas/023-resource-patterns/solution.test.ts +20 -0
- package/katas/023-resource-patterns/solution.ts +13 -0
- package/katas/024-streams-basics/SENSEI.md +61 -0
- package/katas/024-streams-basics/solution.test.ts +30 -0
- package/katas/024-streams-basics/solution.ts +16 -0
- package/katas/025-stream-operations/SENSEI.md +59 -0
- package/katas/025-stream-operations/solution.test.ts +26 -0
- package/katas/025-stream-operations/solution.ts +17 -0
- package/katas/026-combining-streams/SENSEI.md +54 -0
- package/katas/026-combining-streams/solution.test.ts +20 -0
- package/katas/026-combining-streams/solution.ts +16 -0
- package/katas/027-data-pipelines/SENSEI.md +58 -0
- package/katas/027-data-pipelines/solution.test.ts +22 -0
- package/katas/027-data-pipelines/solution.ts +16 -0
- package/katas/028-logging-and-spans/SENSEI.md +58 -0
- package/katas/028-logging-and-spans/solution.test.ts +50 -0
- package/katas/028-logging-and-spans/solution.ts +20 -0
- package/katas/029-http-client/SENSEI.md +59 -0
- package/katas/029-http-client/solution.test.ts +49 -0
- package/katas/029-http-client/solution.ts +24 -0
- package/katas/030-capstone/SENSEI.md +63 -0
- package/katas/030-capstone/solution.test.ts +67 -0
- package/katas/030-capstone/solution.ts +55 -0
- package/katas/031-config-and-environment/SENSEI.md +77 -0
- package/katas/031-config-and-environment/solution.test.ts +38 -0
- package/katas/031-config-and-environment/solution.ts +11 -0
- package/katas/032-cause-and-defects/SENSEI.md +90 -0
- package/katas/032-cause-and-defects/solution.test.ts +50 -0
- package/katas/032-cause-and-defects/solution.ts +23 -0
- package/katas/033-pattern-matching/SENSEI.md +86 -0
- package/katas/033-pattern-matching/solution.test.ts +36 -0
- package/katas/033-pattern-matching/solution.ts +28 -0
- package/katas/034-deferred-and-coordination/SENSEI.md +85 -0
- package/katas/034-deferred-and-coordination/solution.test.ts +25 -0
- package/katas/034-deferred-and-coordination/solution.ts +24 -0
- package/katas/035-queue-and-backpressure/SENSEI.md +100 -0
- package/katas/035-queue-and-backpressure/solution.test.ts +25 -0
- package/katas/035-queue-and-backpressure/solution.ts +21 -0
- package/katas/036-schema-advanced/SENSEI.md +81 -0
- package/katas/036-schema-advanced/solution.test.ts +55 -0
- package/katas/036-schema-advanced/solution.ts +19 -0
- package/katas/037-cache-and-memoization/SENSEI.md +73 -0
- package/katas/037-cache-and-memoization/solution.test.ts +47 -0
- package/katas/037-cache-and-memoization/solution.ts +24 -0
- package/katas/038-metrics/SENSEI.md +91 -0
- package/katas/038-metrics/solution.test.ts +39 -0
- package/katas/038-metrics/solution.ts +23 -0
- package/katas/039-managed-runtime/SENSEI.md +75 -0
- package/katas/039-managed-runtime/solution.test.ts +29 -0
- package/katas/039-managed-runtime/solution.ts +19 -0
- package/katas/040-request-batching/SENSEI.md +87 -0
- package/katas/040-request-batching/solution.test.ts +56 -0
- package/katas/040-request-batching/solution.ts +32 -0
- package/package.json +22 -0
- package/skills/effect-patterns-building-apis/SKILL.md +2393 -0
- package/skills/effect-patterns-building-data-pipelines/SKILL.md +1876 -0
- package/skills/effect-patterns-concurrency/SKILL.md +2999 -0
- package/skills/effect-patterns-concurrency-getting-started/SKILL.md +351 -0
- package/skills/effect-patterns-core-concepts/SKILL.md +3199 -0
- package/skills/effect-patterns-domain-modeling/SKILL.md +1385 -0
- package/skills/effect-patterns-error-handling/SKILL.md +1212 -0
- package/skills/effect-patterns-error-handling-resilience/SKILL.md +179 -0
- package/skills/effect-patterns-error-management/SKILL.md +1668 -0
- package/skills/effect-patterns-getting-started/SKILL.md +237 -0
- package/skills/effect-patterns-making-http-requests/SKILL.md +1756 -0
- package/skills/effect-patterns-observability/SKILL.md +1586 -0
- package/skills/effect-patterns-platform/SKILL.md +1195 -0
- package/skills/effect-patterns-platform-getting-started/SKILL.md +179 -0
- package/skills/effect-patterns-project-setup--execution/SKILL.md +233 -0
- package/skills/effect-patterns-resource-management/SKILL.md +827 -0
- package/skills/effect-patterns-scheduling/SKILL.md +451 -0
- package/skills/effect-patterns-scheduling-periodic-tasks/SKILL.md +763 -0
- package/skills/effect-patterns-streams/SKILL.md +2052 -0
- package/skills/effect-patterns-streams-getting-started/SKILL.md +421 -0
- package/skills/effect-patterns-streams-sinks/SKILL.md +1181 -0
- package/skills/effect-patterns-testing/SKILL.md +1632 -0
- package/skills/effect-patterns-tooling-and-debugging/SKILL.md +1125 -0
- package/skills/effect-patterns-value-handling/SKILL.md +676 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,1756 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-patterns-making-http-requests
|
|
3
|
+
description: Effect-TS patterns for Making Http Requests. Use when working with making http requests in Effect-TS applications.
|
|
4
|
+
---
|
|
5
|
+
# Effect-TS Patterns: Making Http Requests
|
|
6
|
+
This skill provides 10 curated Effect-TS patterns for making http requests.
|
|
7
|
+
Use this skill when working on tasks related to:
|
|
8
|
+
- making http requests
|
|
9
|
+
- Best practices in Effect-TS applications
|
|
10
|
+
- Real-world patterns and solutions
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 🟢 Beginner Patterns
|
|
15
|
+
|
|
16
|
+
### Parse JSON Responses Safely
|
|
17
|
+
|
|
18
|
+
**Rule:** Always validate HTTP responses with Schema to catch API changes at runtime.
|
|
19
|
+
|
|
20
|
+
**Good Example:**
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { Effect, Console } from "effect"
|
|
24
|
+
import { Schema } from "effect"
|
|
25
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"
|
|
26
|
+
import { NodeHttpClient, NodeRuntime } from "@effect/platform-node"
|
|
27
|
+
|
|
28
|
+
// ============================================
|
|
29
|
+
// 1. Define response schemas
|
|
30
|
+
// ============================================
|
|
31
|
+
|
|
32
|
+
const PostSchema = Schema.Struct({
|
|
33
|
+
id: Schema.Number,
|
|
34
|
+
title: Schema.String,
|
|
35
|
+
body: Schema.String,
|
|
36
|
+
userId: Schema.Number,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
type Post = Schema.Schema.Type<typeof PostSchema>
|
|
40
|
+
|
|
41
|
+
const PostArraySchema = Schema.Array(PostSchema)
|
|
42
|
+
|
|
43
|
+
// ============================================
|
|
44
|
+
// 2. Fetch and validate single item
|
|
45
|
+
// ============================================
|
|
46
|
+
|
|
47
|
+
const getPost = (id: number) =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const client = yield* HttpClient.HttpClient
|
|
50
|
+
|
|
51
|
+
const response = yield* client.get(
|
|
52
|
+
`https://jsonplaceholder.typicode.com/posts/${id}`
|
|
53
|
+
)
|
|
54
|
+
const json = yield* HttpClientResponse.json(response)
|
|
55
|
+
|
|
56
|
+
// Validate against schema - fails if data doesn't match
|
|
57
|
+
const post = yield* Schema.decodeUnknown(PostSchema)(json)
|
|
58
|
+
|
|
59
|
+
return post
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ============================================
|
|
63
|
+
// 3. Fetch and validate array
|
|
64
|
+
// ============================================
|
|
65
|
+
|
|
66
|
+
const getPosts = Effect.gen(function* () {
|
|
67
|
+
const client = yield* HttpClient.HttpClient
|
|
68
|
+
|
|
69
|
+
const response = yield* client.get(
|
|
70
|
+
"https://jsonplaceholder.typicode.com/posts"
|
|
71
|
+
)
|
|
72
|
+
const json = yield* HttpClientResponse.json(response)
|
|
73
|
+
|
|
74
|
+
// Validate array of posts
|
|
75
|
+
const posts = yield* Schema.decodeUnknown(PostArraySchema)(json)
|
|
76
|
+
|
|
77
|
+
return posts
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ============================================
|
|
81
|
+
// 4. Handle validation errors
|
|
82
|
+
// ============================================
|
|
83
|
+
|
|
84
|
+
const safeGetPost = (id: number) =>
|
|
85
|
+
getPost(id).pipe(
|
|
86
|
+
Effect.catchTag("ParseError", (error) =>
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
yield* Console.error(`Invalid response format: ${error.message}`)
|
|
89
|
+
// Return a default or fail differently
|
|
90
|
+
return yield* Effect.fail(new Error(`Post ${id} has invalid format`))
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// ============================================
|
|
96
|
+
// 5. Schema with optional fields
|
|
97
|
+
// ============================================
|
|
98
|
+
|
|
99
|
+
const UserSchema = Schema.Struct({
|
|
100
|
+
id: Schema.Number,
|
|
101
|
+
name: Schema.String,
|
|
102
|
+
email: Schema.String,
|
|
103
|
+
phone: Schema.optional(Schema.String), // May not exist
|
|
104
|
+
website: Schema.optional(Schema.String),
|
|
105
|
+
company: Schema.optional(
|
|
106
|
+
Schema.Struct({
|
|
107
|
+
name: Schema.String,
|
|
108
|
+
catchPhrase: Schema.optional(Schema.String),
|
|
109
|
+
})
|
|
110
|
+
),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const getUser = (id: number) =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const client = yield* HttpClient.HttpClient
|
|
116
|
+
|
|
117
|
+
const response = yield* client.get(
|
|
118
|
+
`https://jsonplaceholder.typicode.com/users/${id}`
|
|
119
|
+
)
|
|
120
|
+
const json = yield* HttpClientResponse.json(response)
|
|
121
|
+
|
|
122
|
+
return yield* Schema.decodeUnknown(UserSchema)(json)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ============================================
|
|
126
|
+
// 6. Run examples
|
|
127
|
+
// ============================================
|
|
128
|
+
|
|
129
|
+
const program = Effect.gen(function* () {
|
|
130
|
+
yield* Console.log("=== Validated Single Post ===")
|
|
131
|
+
const post = yield* getPost(1)
|
|
132
|
+
yield* Console.log(`Title: ${post.title}`)
|
|
133
|
+
|
|
134
|
+
yield* Console.log("\n=== Validated Posts Array ===")
|
|
135
|
+
const posts = yield* getPosts
|
|
136
|
+
yield* Console.log(`Fetched ${posts.length} posts`)
|
|
137
|
+
|
|
138
|
+
yield* Console.log("\n=== User with Optional Fields ===")
|
|
139
|
+
const user = yield* getUser(1)
|
|
140
|
+
yield* Console.log(`User: ${user.name}`)
|
|
141
|
+
yield* Console.log(`Company: ${user.company?.name ?? "N/A"}`)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
program.pipe(
|
|
145
|
+
Effect.provide(NodeHttpClient.layer),
|
|
146
|
+
NodeRuntime.runMain
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Rationale:**
|
|
151
|
+
|
|
152
|
+
Use Effect Schema to validate HTTP JSON responses, ensuring the data matches your expected types at runtime.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
APIs can change without warning:
|
|
158
|
+
|
|
159
|
+
1. **Fields disappear** - Backend removes a field
|
|
160
|
+
2. **Types change** - String becomes number
|
|
161
|
+
3. **Nulls appear** - Required field becomes optional
|
|
162
|
+
4. **New fields** - Extra data you didn't expect
|
|
163
|
+
|
|
164
|
+
Schema validation catches these issues immediately.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### Your First HTTP Request
|
|
171
|
+
|
|
172
|
+
**Rule:** Use @effect/platform HttpClient for type-safe HTTP requests with automatic error handling.
|
|
173
|
+
|
|
174
|
+
**Good Example:**
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { Effect, Console } from "effect"
|
|
178
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"
|
|
179
|
+
import { NodeHttpClient, NodeRuntime } from "@effect/platform-node"
|
|
180
|
+
|
|
181
|
+
// ============================================
|
|
182
|
+
// 1. Simple GET request
|
|
183
|
+
// ============================================
|
|
184
|
+
|
|
185
|
+
const simpleGet = Effect.gen(function* () {
|
|
186
|
+
const client = yield* HttpClient.HttpClient
|
|
187
|
+
|
|
188
|
+
// Make a GET request
|
|
189
|
+
const response = yield* client.get("https://jsonplaceholder.typicode.com/posts/1")
|
|
190
|
+
|
|
191
|
+
// Get response as JSON
|
|
192
|
+
const json = yield* HttpClientResponse.json(response)
|
|
193
|
+
|
|
194
|
+
return json
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// ============================================
|
|
198
|
+
// 2. GET with typed response
|
|
199
|
+
// ============================================
|
|
200
|
+
|
|
201
|
+
interface Post {
|
|
202
|
+
id: number
|
|
203
|
+
title: string
|
|
204
|
+
body: string
|
|
205
|
+
userId: number
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const getPost = (id: number) =>
|
|
209
|
+
Effect.gen(function* () {
|
|
210
|
+
const client = yield* HttpClient.HttpClient
|
|
211
|
+
const response = yield* client.get(
|
|
212
|
+
`https://jsonplaceholder.typicode.com/posts/${id}`
|
|
213
|
+
)
|
|
214
|
+
const post = yield* HttpClientResponse.json(response) as Effect.Effect<Post>
|
|
215
|
+
return post
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ============================================
|
|
219
|
+
// 3. POST with body
|
|
220
|
+
// ============================================
|
|
221
|
+
|
|
222
|
+
const createPost = (title: string, body: string) =>
|
|
223
|
+
Effect.gen(function* () {
|
|
224
|
+
const client = yield* HttpClient.HttpClient
|
|
225
|
+
|
|
226
|
+
const request = HttpClientRequest.post(
|
|
227
|
+
"https://jsonplaceholder.typicode.com/posts"
|
|
228
|
+
).pipe(
|
|
229
|
+
HttpClientRequest.jsonBody({ title, body, userId: 1 })
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const response = yield* client.execute(yield* request)
|
|
233
|
+
const created = yield* HttpClientResponse.json(response)
|
|
234
|
+
|
|
235
|
+
return created
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// ============================================
|
|
239
|
+
// 4. Handle errors
|
|
240
|
+
// ============================================
|
|
241
|
+
|
|
242
|
+
const safeGetPost = (id: number) =>
|
|
243
|
+
getPost(id).pipe(
|
|
244
|
+
Effect.catchAll((error) =>
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
yield* Console.error(`Failed to fetch post ${id}: ${error}`)
|
|
247
|
+
return { id, title: "Unavailable", body: "", userId: 0 }
|
|
248
|
+
})
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
// ============================================
|
|
253
|
+
// 5. Run the program
|
|
254
|
+
// ============================================
|
|
255
|
+
|
|
256
|
+
const program = Effect.gen(function* () {
|
|
257
|
+
yield* Console.log("=== Simple GET ===")
|
|
258
|
+
const data = yield* simpleGet
|
|
259
|
+
yield* Console.log(JSON.stringify(data, null, 2))
|
|
260
|
+
|
|
261
|
+
yield* Console.log("\n=== Typed GET ===")
|
|
262
|
+
const post = yield* getPost(1)
|
|
263
|
+
yield* Console.log(`Post: ${post.title}`)
|
|
264
|
+
|
|
265
|
+
yield* Console.log("\n=== POST Request ===")
|
|
266
|
+
const created = yield* createPost("My New Post", "This is the body")
|
|
267
|
+
yield* Console.log(`Created: ${JSON.stringify(created)}`)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Provide the HTTP client implementation
|
|
271
|
+
program.pipe(
|
|
272
|
+
Effect.provide(NodeHttpClient.layer),
|
|
273
|
+
NodeRuntime.runMain
|
|
274
|
+
)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Rationale:**
|
|
278
|
+
|
|
279
|
+
Use Effect's `HttpClient` from `@effect/platform` to make HTTP requests with built-in error handling, retries, and type safety.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
Effect's HttpClient is better than `fetch`:
|
|
285
|
+
|
|
286
|
+
1. **Type-safe errors** - Network failures are typed, not exceptions
|
|
287
|
+
2. **Automatic JSON parsing** - No manual `.json()` calls
|
|
288
|
+
3. **Composable** - Chain requests, add retries, timeouts
|
|
289
|
+
4. **Testable** - Easy to mock in tests
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
## 🟡 Intermediate Patterns
|
|
297
|
+
|
|
298
|
+
### Retry HTTP Requests with Backoff
|
|
299
|
+
|
|
300
|
+
**Rule:** Use Schedule to retry failed HTTP requests with configurable backoff strategies.
|
|
301
|
+
|
|
302
|
+
**Good Example:**
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { Effect, Schedule, Duration, Data } from "effect"
|
|
306
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse, HttpClientError } from "@effect/platform"
|
|
307
|
+
|
|
308
|
+
// ============================================
|
|
309
|
+
// 1. Basic retry with exponential backoff
|
|
310
|
+
// ============================================
|
|
311
|
+
|
|
312
|
+
const fetchWithRetry = (url: string) =>
|
|
313
|
+
Effect.gen(function* () {
|
|
314
|
+
const client = yield* HttpClient.HttpClient
|
|
315
|
+
|
|
316
|
+
return yield* client.get(url).pipe(
|
|
317
|
+
Effect.flatMap((response) => HttpClientResponse.json(response)),
|
|
318
|
+
Effect.retry(
|
|
319
|
+
Schedule.exponential("100 millis", 2).pipe(
|
|
320
|
+
Schedule.intersect(Schedule.recurs(5)), // Max 5 retries
|
|
321
|
+
Schedule.jittered // Add randomness
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// ============================================
|
|
328
|
+
// 2. Retry only specific status codes
|
|
329
|
+
// ============================================
|
|
330
|
+
|
|
331
|
+
class RetryableHttpError extends Data.TaggedError("RetryableHttpError")<{
|
|
332
|
+
readonly status: number
|
|
333
|
+
readonly message: string
|
|
334
|
+
}> {}
|
|
335
|
+
|
|
336
|
+
class NonRetryableHttpError extends Data.TaggedError("NonRetryableHttpError")<{
|
|
337
|
+
readonly status: number
|
|
338
|
+
readonly message: string
|
|
339
|
+
}> {}
|
|
340
|
+
|
|
341
|
+
const isRetryable = (status: number): boolean =>
|
|
342
|
+
status === 429 || // Rate limited
|
|
343
|
+
status === 503 || // Service unavailable
|
|
344
|
+
status === 502 || // Bad gateway
|
|
345
|
+
status === 504 || // Gateway timeout
|
|
346
|
+
status >= 500 // Server errors
|
|
347
|
+
|
|
348
|
+
const fetchWithSelectiveRetry = (url: string) =>
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
const client = yield* HttpClient.HttpClient
|
|
351
|
+
|
|
352
|
+
const response = yield* client.get(url).pipe(
|
|
353
|
+
Effect.flatMap((response) => {
|
|
354
|
+
if (response.status >= 400) {
|
|
355
|
+
if (isRetryable(response.status)) {
|
|
356
|
+
return Effect.fail(new RetryableHttpError({
|
|
357
|
+
status: response.status,
|
|
358
|
+
message: `HTTP ${response.status}`,
|
|
359
|
+
}))
|
|
360
|
+
}
|
|
361
|
+
return Effect.fail(new NonRetryableHttpError({
|
|
362
|
+
status: response.status,
|
|
363
|
+
message: `HTTP ${response.status}`,
|
|
364
|
+
}))
|
|
365
|
+
}
|
|
366
|
+
return Effect.succeed(response)
|
|
367
|
+
}),
|
|
368
|
+
Effect.retry({
|
|
369
|
+
schedule: Schedule.exponential("200 millis").pipe(
|
|
370
|
+
Schedule.intersect(Schedule.recurs(3))
|
|
371
|
+
),
|
|
372
|
+
while: (error) => error._tag === "RetryableHttpError",
|
|
373
|
+
})
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return yield* HttpClientResponse.json(response)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// ============================================
|
|
380
|
+
// 3. Retry with logging
|
|
381
|
+
// ============================================
|
|
382
|
+
|
|
383
|
+
const fetchWithRetryLogging = (url: string) =>
|
|
384
|
+
Effect.gen(function* () {
|
|
385
|
+
const client = yield* HttpClient.HttpClient
|
|
386
|
+
|
|
387
|
+
return yield* client.get(url).pipe(
|
|
388
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
389
|
+
Effect.retry(
|
|
390
|
+
Schedule.exponential("100 millis").pipe(
|
|
391
|
+
Schedule.intersect(Schedule.recurs(3)),
|
|
392
|
+
Schedule.tapOutput((_, output) =>
|
|
393
|
+
Effect.log(`Retry attempt, waiting ${Duration.toMillis(output)}ms`)
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
),
|
|
397
|
+
Effect.tapError((error) => Effect.log(`Request failed: ${error}`))
|
|
398
|
+
)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// ============================================
|
|
402
|
+
// 4. Custom retry policy
|
|
403
|
+
// ============================================
|
|
404
|
+
|
|
405
|
+
const customRetryPolicy = Schedule.exponential("500 millis", 2).pipe(
|
|
406
|
+
Schedule.intersect(Schedule.recurs(5)),
|
|
407
|
+
Schedule.union(Schedule.spaced("30 seconds")), // Also retry after 30s
|
|
408
|
+
Schedule.whileOutput((duration) => Duration.lessThanOrEqualTo(duration, "2 minutes")),
|
|
409
|
+
Schedule.jittered
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
// ============================================
|
|
413
|
+
// 5. Retry respecting Retry-After header
|
|
414
|
+
// ============================================
|
|
415
|
+
|
|
416
|
+
const fetchWithRetryAfter = (url: string) =>
|
|
417
|
+
Effect.gen(function* () {
|
|
418
|
+
const client = yield* HttpClient.HttpClient
|
|
419
|
+
|
|
420
|
+
const makeRequest = client.get(url).pipe(
|
|
421
|
+
Effect.flatMap((response) => {
|
|
422
|
+
if (response.status === 429) {
|
|
423
|
+
const retryAfter = response.headers["retry-after"]
|
|
424
|
+
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000
|
|
425
|
+
|
|
426
|
+
return Effect.fail({
|
|
427
|
+
_tag: "RateLimited" as const,
|
|
428
|
+
delay,
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
return Effect.succeed(response)
|
|
432
|
+
})
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return yield* makeRequest.pipe(
|
|
436
|
+
Effect.retry(
|
|
437
|
+
Schedule.recurWhile<{ _tag: "RateLimited"; delay: number }>(
|
|
438
|
+
(error) => error._tag === "RateLimited"
|
|
439
|
+
).pipe(
|
|
440
|
+
Schedule.intersect(Schedule.recurs(3)),
|
|
441
|
+
Schedule.delayed((_, error) => Duration.millis(error.delay))
|
|
442
|
+
)
|
|
443
|
+
),
|
|
444
|
+
Effect.flatMap((r) => HttpClientResponse.json(r))
|
|
445
|
+
)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// ============================================
|
|
449
|
+
// 6. Usage
|
|
450
|
+
// ============================================
|
|
451
|
+
|
|
452
|
+
const program = Effect.gen(function* () {
|
|
453
|
+
yield* Effect.log("Fetching with retry...")
|
|
454
|
+
|
|
455
|
+
const data = yield* fetchWithRetry("https://api.example.com/data").pipe(
|
|
456
|
+
Effect.catchAll((error) => {
|
|
457
|
+
return Effect.succeed({ error: "All retries exhausted" })
|
|
458
|
+
})
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
yield* Effect.log(`Result: ${JSON.stringify(data)}`)
|
|
462
|
+
})
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
**Rationale:**
|
|
466
|
+
|
|
467
|
+
Use Effect's `retry` with `Schedule` to automatically retry failed HTTP requests with exponential backoff and jitter.
|
|
468
|
+
|
|
469
|
+
---
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
HTTP requests fail for transient reasons:
|
|
473
|
+
|
|
474
|
+
1. **Network issues** - Temporary connectivity problems
|
|
475
|
+
2. **Server overload** - 503 Service Unavailable
|
|
476
|
+
3. **Rate limits** - 429 Too Many Requests
|
|
477
|
+
4. **Timeouts** - Slow responses
|
|
478
|
+
|
|
479
|
+
Proper retry logic handles these gracefully.
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
### Log HTTP Requests and Responses
|
|
486
|
+
|
|
487
|
+
**Rule:** Use Effect's logging to trace HTTP requests for debugging and monitoring.
|
|
488
|
+
|
|
489
|
+
**Good Example:**
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
import { Effect, Duration } from "effect"
|
|
493
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"
|
|
494
|
+
|
|
495
|
+
// ============================================
|
|
496
|
+
// 1. Simple request/response logging
|
|
497
|
+
// ============================================
|
|
498
|
+
|
|
499
|
+
const withLogging = <A, E>(
|
|
500
|
+
request: Effect.Effect<A, E, HttpClient.HttpClient>
|
|
501
|
+
): Effect.Effect<A, E, HttpClient.HttpClient> =>
|
|
502
|
+
Effect.gen(function* () {
|
|
503
|
+
const startTime = Date.now()
|
|
504
|
+
yield* Effect.log("→ HTTP Request starting...")
|
|
505
|
+
|
|
506
|
+
const result = yield* request
|
|
507
|
+
|
|
508
|
+
const duration = Date.now() - startTime
|
|
509
|
+
yield* Effect.log(`← HTTP Response received (${duration}ms)`)
|
|
510
|
+
|
|
511
|
+
return result
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
// ============================================
|
|
515
|
+
// 2. Detailed request logging
|
|
516
|
+
// ============================================
|
|
517
|
+
|
|
518
|
+
interface RequestLog {
|
|
519
|
+
method: string
|
|
520
|
+
url: string
|
|
521
|
+
headers: Record<string, string>
|
|
522
|
+
body?: unknown
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
interface ResponseLog {
|
|
526
|
+
status: number
|
|
527
|
+
headers: Record<string, string>
|
|
528
|
+
duration: number
|
|
529
|
+
size?: number
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const makeLoggingClient = Effect.gen(function* () {
|
|
533
|
+
const baseClient = yield* HttpClient.HttpClient
|
|
534
|
+
|
|
535
|
+
const logRequest = (method: string, url: string, headers: Record<string, string>) =>
|
|
536
|
+
Effect.log("HTTP Request").pipe(
|
|
537
|
+
Effect.annotateLogs({
|
|
538
|
+
method,
|
|
539
|
+
url,
|
|
540
|
+
headers: JSON.stringify(headers),
|
|
541
|
+
})
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
const logResponse = (status: number, duration: number, headers: Record<string, string>) =>
|
|
545
|
+
Effect.log("HTTP Response").pipe(
|
|
546
|
+
Effect.annotateLogs({
|
|
547
|
+
status: String(status),
|
|
548
|
+
duration: `${duration}ms`,
|
|
549
|
+
headers: JSON.stringify(headers),
|
|
550
|
+
})
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
get: <T>(url: string, options?: { headers?: Record<string, string> }) =>
|
|
555
|
+
Effect.gen(function* () {
|
|
556
|
+
const headers = options?.headers ?? {}
|
|
557
|
+
yield* logRequest("GET", url, headers)
|
|
558
|
+
const startTime = Date.now()
|
|
559
|
+
|
|
560
|
+
const response = yield* baseClient.get(url)
|
|
561
|
+
|
|
562
|
+
yield* logResponse(
|
|
563
|
+
response.status,
|
|
564
|
+
Date.now() - startTime,
|
|
565
|
+
response.headers
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
return yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
569
|
+
}),
|
|
570
|
+
|
|
571
|
+
post: <T>(url: string, body: unknown, options?: { headers?: Record<string, string> }) =>
|
|
572
|
+
Effect.gen(function* () {
|
|
573
|
+
const headers = options?.headers ?? {}
|
|
574
|
+
yield* logRequest("POST", url, headers).pipe(
|
|
575
|
+
Effect.annotateLogs("body", JSON.stringify(body).slice(0, 200))
|
|
576
|
+
)
|
|
577
|
+
const startTime = Date.now()
|
|
578
|
+
|
|
579
|
+
const request = yield* HttpClientRequest.post(url).pipe(
|
|
580
|
+
HttpClientRequest.jsonBody(body)
|
|
581
|
+
)
|
|
582
|
+
const response = yield* baseClient.execute(request)
|
|
583
|
+
|
|
584
|
+
yield* logResponse(
|
|
585
|
+
response.status,
|
|
586
|
+
Date.now() - startTime,
|
|
587
|
+
response.headers
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
return yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
591
|
+
}),
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
// ============================================
|
|
596
|
+
// 3. Log with span for timing
|
|
597
|
+
// ============================================
|
|
598
|
+
|
|
599
|
+
const fetchWithSpan = (url: string) =>
|
|
600
|
+
Effect.gen(function* () {
|
|
601
|
+
const client = yield* HttpClient.HttpClient
|
|
602
|
+
|
|
603
|
+
return yield* client.get(url).pipe(
|
|
604
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
605
|
+
Effect.withLogSpan(`HTTP GET ${url}`)
|
|
606
|
+
)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// ============================================
|
|
610
|
+
// 4. Conditional logging (debug mode)
|
|
611
|
+
// ============================================
|
|
612
|
+
|
|
613
|
+
const makeConditionalLoggingClient = (debug: boolean) =>
|
|
614
|
+
Effect.gen(function* () {
|
|
615
|
+
const baseClient = yield* HttpClient.HttpClient
|
|
616
|
+
|
|
617
|
+
const maybeLog = (message: string, data?: Record<string, unknown>) =>
|
|
618
|
+
debug
|
|
619
|
+
? Effect.log(message).pipe(
|
|
620
|
+
data ? Effect.annotateLogs(data) : (e) => e
|
|
621
|
+
)
|
|
622
|
+
: Effect.void
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
get: <T>(url: string) =>
|
|
626
|
+
Effect.gen(function* () {
|
|
627
|
+
yield* maybeLog("HTTP Request", { method: "GET", url })
|
|
628
|
+
const startTime = Date.now()
|
|
629
|
+
|
|
630
|
+
const response = yield* baseClient.get(url)
|
|
631
|
+
|
|
632
|
+
yield* maybeLog("HTTP Response", {
|
|
633
|
+
status: String(response.status),
|
|
634
|
+
duration: `${Date.now() - startTime}ms`,
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
return yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
638
|
+
}),
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
// ============================================
|
|
643
|
+
// 5. Request ID tracking
|
|
644
|
+
// ============================================
|
|
645
|
+
|
|
646
|
+
const makeTrackedClient = Effect.gen(function* () {
|
|
647
|
+
const baseClient = yield* HttpClient.HttpClient
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
get: <T>(url: string) =>
|
|
651
|
+
Effect.gen(function* () {
|
|
652
|
+
const requestId = crypto.randomUUID().slice(0, 8)
|
|
653
|
+
|
|
654
|
+
yield* Effect.log("HTTP Request").pipe(
|
|
655
|
+
Effect.annotateLogs({
|
|
656
|
+
requestId,
|
|
657
|
+
method: "GET",
|
|
658
|
+
url,
|
|
659
|
+
})
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
const startTime = Date.now()
|
|
663
|
+
const response = yield* baseClient.get(url)
|
|
664
|
+
|
|
665
|
+
yield* Effect.log("HTTP Response").pipe(
|
|
666
|
+
Effect.annotateLogs({
|
|
667
|
+
requestId,
|
|
668
|
+
status: String(response.status),
|
|
669
|
+
duration: `${Date.now() - startTime}ms`,
|
|
670
|
+
})
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
return yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
// ============================================
|
|
679
|
+
// 6. Error logging
|
|
680
|
+
// ============================================
|
|
681
|
+
|
|
682
|
+
const fetchWithErrorLogging = (url: string) =>
|
|
683
|
+
Effect.gen(function* () {
|
|
684
|
+
const client = yield* HttpClient.HttpClient
|
|
685
|
+
|
|
686
|
+
return yield* client.get(url).pipe(
|
|
687
|
+
Effect.flatMap((response) => {
|
|
688
|
+
if (response.status >= 400) {
|
|
689
|
+
return Effect.gen(function* () {
|
|
690
|
+
yield* Effect.logError("HTTP Error").pipe(
|
|
691
|
+
Effect.annotateLogs({
|
|
692
|
+
url,
|
|
693
|
+
status: String(response.status),
|
|
694
|
+
})
|
|
695
|
+
)
|
|
696
|
+
return yield* Effect.fail(new Error(`HTTP ${response.status}`))
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
return Effect.succeed(response)
|
|
700
|
+
}),
|
|
701
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
702
|
+
Effect.tapError((error) =>
|
|
703
|
+
Effect.logError("Request failed").pipe(
|
|
704
|
+
Effect.annotateLogs({
|
|
705
|
+
url,
|
|
706
|
+
error: String(error),
|
|
707
|
+
})
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
// ============================================
|
|
714
|
+
// 7. Usage
|
|
715
|
+
// ============================================
|
|
716
|
+
|
|
717
|
+
const program = Effect.gen(function* () {
|
|
718
|
+
const client = yield* makeLoggingClient
|
|
719
|
+
|
|
720
|
+
yield* Effect.log("Starting HTTP operations...")
|
|
721
|
+
|
|
722
|
+
const data = yield* client.get("https://api.example.com/users")
|
|
723
|
+
|
|
724
|
+
yield* Effect.log("Operations complete")
|
|
725
|
+
})
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
**Rationale:**
|
|
729
|
+
|
|
730
|
+
Wrap HTTP clients with logging middleware to capture request details, response info, and timing for debugging and observability.
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
HTTP logging helps with:
|
|
736
|
+
|
|
737
|
+
1. **Debugging** - See what's being sent/received
|
|
738
|
+
2. **Performance** - Track slow requests
|
|
739
|
+
3. **Auditing** - Record API usage
|
|
740
|
+
4. **Troubleshooting** - Diagnose production issues
|
|
741
|
+
|
|
742
|
+
---
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
### Cache HTTP Responses
|
|
747
|
+
|
|
748
|
+
**Rule:** Use an in-memory or persistent cache to store HTTP responses.
|
|
749
|
+
|
|
750
|
+
**Good Example:**
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
import { Effect, Ref, HashMap, Option, Duration } from "effect"
|
|
754
|
+
import { HttpClient, HttpClientResponse } from "@effect/platform"
|
|
755
|
+
|
|
756
|
+
// ============================================
|
|
757
|
+
// 1. Simple in-memory cache
|
|
758
|
+
// ============================================
|
|
759
|
+
|
|
760
|
+
interface CacheEntry<T> {
|
|
761
|
+
readonly data: T
|
|
762
|
+
readonly timestamp: number
|
|
763
|
+
readonly ttl: number
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const makeCache = <T>() =>
|
|
767
|
+
Effect.gen(function* () {
|
|
768
|
+
const store = yield* Ref.make(HashMap.empty<string, CacheEntry<T>>())
|
|
769
|
+
|
|
770
|
+
const get = (key: string): Effect.Effect<Option.Option<T>> =>
|
|
771
|
+
Ref.get(store).pipe(
|
|
772
|
+
Effect.map((map) => {
|
|
773
|
+
const entry = HashMap.get(map, key)
|
|
774
|
+
if (entry._tag === "None") return Option.none()
|
|
775
|
+
|
|
776
|
+
const now = Date.now()
|
|
777
|
+
if (now > entry.value.timestamp + entry.value.ttl) {
|
|
778
|
+
return Option.none() // Expired
|
|
779
|
+
}
|
|
780
|
+
return Option.some(entry.value.data)
|
|
781
|
+
})
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
const set = (key: string, data: T, ttl: number): Effect.Effect<void> =>
|
|
785
|
+
Ref.update(store, (map) =>
|
|
786
|
+
HashMap.set(map, key, {
|
|
787
|
+
data,
|
|
788
|
+
timestamp: Date.now(),
|
|
789
|
+
ttl,
|
|
790
|
+
})
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
const invalidate = (key: string): Effect.Effect<void> =>
|
|
794
|
+
Ref.update(store, (map) => HashMap.remove(map, key))
|
|
795
|
+
|
|
796
|
+
const clear = (): Effect.Effect<void> =>
|
|
797
|
+
Ref.set(store, HashMap.empty())
|
|
798
|
+
|
|
799
|
+
return { get, set, invalidate, clear }
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
// ============================================
|
|
803
|
+
// 2. Cached HTTP client
|
|
804
|
+
// ============================================
|
|
805
|
+
|
|
806
|
+
interface CachedHttpClient {
|
|
807
|
+
readonly get: <T>(
|
|
808
|
+
url: string,
|
|
809
|
+
options?: { ttl?: Duration.DurationInput }
|
|
810
|
+
) => Effect.Effect<T, Error>
|
|
811
|
+
readonly invalidate: (url: string) => Effect.Effect<void>
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const makeCachedHttpClient = Effect.gen(function* () {
|
|
815
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
816
|
+
const cache = yield* makeCache<unknown>()
|
|
817
|
+
|
|
818
|
+
const client: CachedHttpClient = {
|
|
819
|
+
get: <T>(url: string, options?: { ttl?: Duration.DurationInput }) => {
|
|
820
|
+
const ttl = options?.ttl ? Duration.toMillis(Duration.decode(options.ttl)) : 60000
|
|
821
|
+
|
|
822
|
+
return Effect.gen(function* () {
|
|
823
|
+
// Check cache first
|
|
824
|
+
const cached = yield* cache.get(url)
|
|
825
|
+
if (Option.isSome(cached)) {
|
|
826
|
+
yield* Effect.log(`Cache hit: ${url}`)
|
|
827
|
+
return cached.value as T
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
yield* Effect.log(`Cache miss: ${url}`)
|
|
831
|
+
|
|
832
|
+
// Fetch from network
|
|
833
|
+
const response = yield* httpClient.get(url)
|
|
834
|
+
const data = yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
835
|
+
|
|
836
|
+
// Store in cache
|
|
837
|
+
yield* cache.set(url, data, ttl)
|
|
838
|
+
|
|
839
|
+
return data
|
|
840
|
+
})
|
|
841
|
+
},
|
|
842
|
+
|
|
843
|
+
invalidate: (url) => cache.invalidate(url),
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return client
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
// ============================================
|
|
850
|
+
// 3. Stale-while-revalidate pattern
|
|
851
|
+
// ============================================
|
|
852
|
+
|
|
853
|
+
interface SWRCache<T> {
|
|
854
|
+
readonly data: T
|
|
855
|
+
readonly timestamp: number
|
|
856
|
+
readonly staleAfter: number
|
|
857
|
+
readonly expireAfter: number
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const makeSWRClient = Effect.gen(function* () {
|
|
861
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
862
|
+
const cache = yield* Ref.make(HashMap.empty<string, SWRCache<unknown>>())
|
|
863
|
+
|
|
864
|
+
return {
|
|
865
|
+
get: <T>(
|
|
866
|
+
url: string,
|
|
867
|
+
options: {
|
|
868
|
+
staleAfter: Duration.DurationInput
|
|
869
|
+
expireAfter: Duration.DurationInput
|
|
870
|
+
}
|
|
871
|
+
) =>
|
|
872
|
+
Effect.gen(function* () {
|
|
873
|
+
const now = Date.now()
|
|
874
|
+
const staleMs = Duration.toMillis(Duration.decode(options.staleAfter))
|
|
875
|
+
const expireMs = Duration.toMillis(Duration.decode(options.expireAfter))
|
|
876
|
+
|
|
877
|
+
const cached = yield* Ref.get(cache).pipe(
|
|
878
|
+
Effect.map((map) => HashMap.get(map, url))
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
if (cached._tag === "Some") {
|
|
882
|
+
const entry = cached.value
|
|
883
|
+
const age = now - entry.timestamp
|
|
884
|
+
|
|
885
|
+
if (age < staleMs) {
|
|
886
|
+
// Fresh - return immediately
|
|
887
|
+
return entry.data as T
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (age < expireMs) {
|
|
891
|
+
// Stale - return cached, revalidate in background
|
|
892
|
+
yield* Effect.fork(
|
|
893
|
+
httpClient.get(url).pipe(
|
|
894
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
895
|
+
Effect.flatMap((data) =>
|
|
896
|
+
Ref.update(cache, (map) =>
|
|
897
|
+
HashMap.set(map, url, {
|
|
898
|
+
data,
|
|
899
|
+
timestamp: Date.now(),
|
|
900
|
+
staleAfter: staleMs,
|
|
901
|
+
expireAfter: expireMs,
|
|
902
|
+
})
|
|
903
|
+
)
|
|
904
|
+
),
|
|
905
|
+
Effect.catchAll(() => Effect.void) // Ignore errors
|
|
906
|
+
)
|
|
907
|
+
)
|
|
908
|
+
return entry.data as T
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Expired or missing - fetch fresh
|
|
913
|
+
const response = yield* httpClient.get(url)
|
|
914
|
+
const data = yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
915
|
+
|
|
916
|
+
yield* Ref.update(cache, (map) =>
|
|
917
|
+
HashMap.set(map, url, {
|
|
918
|
+
data,
|
|
919
|
+
timestamp: now,
|
|
920
|
+
staleAfter: staleMs,
|
|
921
|
+
expireAfter: expireMs,
|
|
922
|
+
})
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
return data
|
|
926
|
+
}),
|
|
927
|
+
}
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
// ============================================
|
|
931
|
+
// 4. Cache with request deduplication
|
|
932
|
+
// ============================================
|
|
933
|
+
|
|
934
|
+
const makeDeduplicatedClient = Effect.gen(function* () {
|
|
935
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
936
|
+
const inFlight = yield* Ref.make(HashMap.empty<string, Effect.Effect<unknown>>())
|
|
937
|
+
const cache = yield* makeCache<unknown>()
|
|
938
|
+
|
|
939
|
+
return {
|
|
940
|
+
get: <T>(url: string, ttl: number = 60000) =>
|
|
941
|
+
Effect.gen(function* () {
|
|
942
|
+
// Check cache
|
|
943
|
+
const cached = yield* cache.get(url)
|
|
944
|
+
if (Option.isSome(cached)) {
|
|
945
|
+
return cached.value as T
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Check if request already in flight
|
|
949
|
+
const pending = yield* Ref.get(inFlight).pipe(
|
|
950
|
+
Effect.map((map) => HashMap.get(map, url))
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
if (pending._tag === "Some") {
|
|
954
|
+
yield* Effect.log(`Deduplicating request: ${url}`)
|
|
955
|
+
return (yield* pending.value) as T
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Make the request
|
|
959
|
+
const request = httpClient.get(url).pipe(
|
|
960
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
961
|
+
Effect.tap((data) => cache.set(url, data, ttl)),
|
|
962
|
+
Effect.ensuring(
|
|
963
|
+
Ref.update(inFlight, (map) => HashMap.remove(map, url))
|
|
964
|
+
)
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
// Store in-flight request
|
|
968
|
+
yield* Ref.update(inFlight, (map) => HashMap.set(map, url, request))
|
|
969
|
+
|
|
970
|
+
return (yield* request) as T
|
|
971
|
+
}),
|
|
972
|
+
}
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
// ============================================
|
|
976
|
+
// 5. Usage
|
|
977
|
+
// ============================================
|
|
978
|
+
|
|
979
|
+
const program = Effect.gen(function* () {
|
|
980
|
+
const client = yield* makeCachedHttpClient
|
|
981
|
+
|
|
982
|
+
// First call - cache miss
|
|
983
|
+
yield* client.get("https://api.example.com/users/1", { ttl: "5 minutes" })
|
|
984
|
+
|
|
985
|
+
// Second call - cache hit
|
|
986
|
+
yield* client.get("https://api.example.com/users/1")
|
|
987
|
+
|
|
988
|
+
// Invalidate when data changes
|
|
989
|
+
yield* client.invalidate("https://api.example.com/users/1")
|
|
990
|
+
})
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
**Rationale:**
|
|
994
|
+
|
|
995
|
+
Cache HTTP responses to reduce network calls, improve latency, and handle offline scenarios.
|
|
996
|
+
|
|
997
|
+
---
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
Caching provides:
|
|
1001
|
+
|
|
1002
|
+
1. **Performance** - Avoid redundant network calls
|
|
1003
|
+
2. **Cost reduction** - Fewer API calls
|
|
1004
|
+
3. **Resilience** - Serve stale data when API is down
|
|
1005
|
+
4. **Rate limit safety** - Stay under quotas
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
---
|
|
1010
|
+
|
|
1011
|
+
### Add Timeouts to HTTP Requests
|
|
1012
|
+
|
|
1013
|
+
**Rule:** Always set timeouts on HTTP requests to ensure your application doesn't hang.
|
|
1014
|
+
|
|
1015
|
+
**Good Example:**
|
|
1016
|
+
|
|
1017
|
+
```typescript
|
|
1018
|
+
import { Effect, Duration, Data } from "effect"
|
|
1019
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"
|
|
1020
|
+
|
|
1021
|
+
// ============================================
|
|
1022
|
+
// 1. Basic request timeout
|
|
1023
|
+
// ============================================
|
|
1024
|
+
|
|
1025
|
+
const fetchWithTimeout = (url: string, timeout: Duration.DurationInput) =>
|
|
1026
|
+
Effect.gen(function* () {
|
|
1027
|
+
const client = yield* HttpClient.HttpClient
|
|
1028
|
+
|
|
1029
|
+
return yield* client.get(url).pipe(
|
|
1030
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
1031
|
+
Effect.timeout(timeout)
|
|
1032
|
+
)
|
|
1033
|
+
// Returns Option<A> - None if timed out
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
// ============================================
|
|
1037
|
+
// 2. Timeout with custom error
|
|
1038
|
+
// ============================================
|
|
1039
|
+
|
|
1040
|
+
class RequestTimeoutError extends Data.TaggedError("RequestTimeoutError")<{
|
|
1041
|
+
readonly url: string
|
|
1042
|
+
readonly timeout: Duration.Duration
|
|
1043
|
+
}> {}
|
|
1044
|
+
|
|
1045
|
+
const fetchWithTimeoutError = (url: string, timeout: Duration.DurationInput) =>
|
|
1046
|
+
Effect.gen(function* () {
|
|
1047
|
+
const client = yield* HttpClient.HttpClient
|
|
1048
|
+
|
|
1049
|
+
return yield* client.get(url).pipe(
|
|
1050
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
1051
|
+
Effect.timeoutFail({
|
|
1052
|
+
duration: timeout,
|
|
1053
|
+
onTimeout: () => new RequestTimeoutError({
|
|
1054
|
+
url,
|
|
1055
|
+
timeout: Duration.decode(timeout),
|
|
1056
|
+
}),
|
|
1057
|
+
})
|
|
1058
|
+
)
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
// ============================================
|
|
1062
|
+
// 3. Different timeouts for different phases
|
|
1063
|
+
// ============================================
|
|
1064
|
+
|
|
1065
|
+
const fetchWithPhasedTimeouts = (url: string) =>
|
|
1066
|
+
Effect.gen(function* () {
|
|
1067
|
+
const client = yield* HttpClient.HttpClient
|
|
1068
|
+
|
|
1069
|
+
// Connection timeout (initial)
|
|
1070
|
+
const response = yield* client.get(url).pipe(
|
|
1071
|
+
Effect.timeout("5 seconds"),
|
|
1072
|
+
Effect.flatten,
|
|
1073
|
+
Effect.mapError(() => new Error("Connection timeout"))
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
// Read timeout (body)
|
|
1077
|
+
const body = yield* HttpClientResponse.text(response).pipe(
|
|
1078
|
+
Effect.timeout("30 seconds"),
|
|
1079
|
+
Effect.flatten,
|
|
1080
|
+
Effect.mapError(() => new Error("Read timeout"))
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
return body
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
// ============================================
|
|
1087
|
+
// 4. Timeout with fallback
|
|
1088
|
+
// ============================================
|
|
1089
|
+
|
|
1090
|
+
interface ApiResponse {
|
|
1091
|
+
data: unknown
|
|
1092
|
+
cached: boolean
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const fetchWithFallback = (url: string): Effect.Effect<ApiResponse> =>
|
|
1096
|
+
Effect.gen(function* () {
|
|
1097
|
+
const client = yield* HttpClient.HttpClient
|
|
1098
|
+
|
|
1099
|
+
return yield* client.get(url).pipe(
|
|
1100
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
1101
|
+
Effect.map((data) => ({ data, cached: false })),
|
|
1102
|
+
Effect.timeout("5 seconds"),
|
|
1103
|
+
Effect.flatMap((result) =>
|
|
1104
|
+
result._tag === "Some"
|
|
1105
|
+
? Effect.succeed(result.value)
|
|
1106
|
+
: Effect.succeed({ data: null, cached: true }) // Fallback
|
|
1107
|
+
)
|
|
1108
|
+
)
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
// ============================================
|
|
1112
|
+
// 5. Timeout with interrupt
|
|
1113
|
+
// ============================================
|
|
1114
|
+
|
|
1115
|
+
const fetchWithInterrupt = (url: string) =>
|
|
1116
|
+
Effect.gen(function* () {
|
|
1117
|
+
const client = yield* HttpClient.HttpClient
|
|
1118
|
+
|
|
1119
|
+
return yield* client.get(url).pipe(
|
|
1120
|
+
Effect.flatMap((r) => HttpClientResponse.json(r)),
|
|
1121
|
+
Effect.interruptible,
|
|
1122
|
+
Effect.timeout("10 seconds")
|
|
1123
|
+
)
|
|
1124
|
+
// Fiber is interrupted if timeout, freeing resources
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
// ============================================
|
|
1128
|
+
// 6. Configurable timeout wrapper
|
|
1129
|
+
// ============================================
|
|
1130
|
+
|
|
1131
|
+
interface TimeoutConfig {
|
|
1132
|
+
readonly connect: Duration.DurationInput
|
|
1133
|
+
readonly read: Duration.DurationInput
|
|
1134
|
+
readonly total: Duration.DurationInput
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const defaultTimeouts: TimeoutConfig = {
|
|
1138
|
+
connect: "5 seconds",
|
|
1139
|
+
read: "30 seconds",
|
|
1140
|
+
total: "60 seconds",
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const createHttpClient = (config: TimeoutConfig = defaultTimeouts) =>
|
|
1144
|
+
Effect.gen(function* () {
|
|
1145
|
+
const baseClient = yield* HttpClient.HttpClient
|
|
1146
|
+
|
|
1147
|
+
return {
|
|
1148
|
+
get: (url: string) =>
|
|
1149
|
+
baseClient.get(url).pipe(
|
|
1150
|
+
Effect.timeout(config.connect),
|
|
1151
|
+
Effect.flatten,
|
|
1152
|
+
Effect.flatMap((r) =>
|
|
1153
|
+
HttpClientResponse.json(r).pipe(
|
|
1154
|
+
Effect.timeout(config.read),
|
|
1155
|
+
Effect.flatten
|
|
1156
|
+
)
|
|
1157
|
+
),
|
|
1158
|
+
Effect.timeout(config.total),
|
|
1159
|
+
Effect.flatten
|
|
1160
|
+
),
|
|
1161
|
+
}
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
// ============================================
|
|
1165
|
+
// 7. Usage
|
|
1166
|
+
// ============================================
|
|
1167
|
+
|
|
1168
|
+
const program = Effect.gen(function* () {
|
|
1169
|
+
yield* Effect.log("Fetching with timeout...")
|
|
1170
|
+
|
|
1171
|
+
const result = yield* fetchWithTimeoutError(
|
|
1172
|
+
"https://api.example.com/slow",
|
|
1173
|
+
"5 seconds"
|
|
1174
|
+
).pipe(
|
|
1175
|
+
Effect.catchTag("RequestTimeoutError", (error) =>
|
|
1176
|
+
Effect.gen(function* () {
|
|
1177
|
+
yield* Effect.log(`Request to ${error.url} timed out`)
|
|
1178
|
+
return { error: "timeout" }
|
|
1179
|
+
})
|
|
1180
|
+
)
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
yield* Effect.log(`Result: ${JSON.stringify(result)}`)
|
|
1184
|
+
})
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
**Rationale:**
|
|
1188
|
+
|
|
1189
|
+
Use Effect's timeout functions to set limits on HTTP request duration, with appropriate fallback handling.
|
|
1190
|
+
|
|
1191
|
+
---
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
HTTP requests can hang indefinitely:
|
|
1195
|
+
|
|
1196
|
+
1. **Server issues** - Unresponsive servers
|
|
1197
|
+
2. **Network problems** - Packets lost
|
|
1198
|
+
3. **Slow responses** - Large payloads
|
|
1199
|
+
4. **Resource leaks** - Connections never closed
|
|
1200
|
+
|
|
1201
|
+
Timeouts prevent these from blocking your application.
|
|
1202
|
+
|
|
1203
|
+
---
|
|
1204
|
+
|
|
1205
|
+
---
|
|
1206
|
+
|
|
1207
|
+
### Model Dependencies as Services
|
|
1208
|
+
|
|
1209
|
+
**Rule:** Model dependencies as services.
|
|
1210
|
+
|
|
1211
|
+
**Good Example:**
|
|
1212
|
+
|
|
1213
|
+
```typescript
|
|
1214
|
+
import { Effect } from "effect";
|
|
1215
|
+
|
|
1216
|
+
// Define Random service with production implementation as default
|
|
1217
|
+
export class Random extends Effect.Service<Random>()("Random", {
|
|
1218
|
+
// Default production implementation
|
|
1219
|
+
sync: () => ({
|
|
1220
|
+
next: Effect.sync(() => Math.random()),
|
|
1221
|
+
}),
|
|
1222
|
+
}) {}
|
|
1223
|
+
|
|
1224
|
+
// Example usage
|
|
1225
|
+
const program = Effect.gen(function* () {
|
|
1226
|
+
const random = yield* Random;
|
|
1227
|
+
const value = yield* random.next;
|
|
1228
|
+
return value;
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// Run with default implementation
|
|
1232
|
+
const programWithLogging = Effect.gen(function* () {
|
|
1233
|
+
const value = yield* Effect.provide(program, Random.Default);
|
|
1234
|
+
yield* Effect.log(`Random value: ${value}`);
|
|
1235
|
+
return value;
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
Effect.runPromise(programWithLogging);
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
**Explanation:**
|
|
1242
|
+
By modeling dependencies as services, you can easily substitute mocked or deterministic implementations for testing, leading to more reliable and predictable tests.
|
|
1243
|
+
|
|
1244
|
+
**Anti-Pattern:**
|
|
1245
|
+
|
|
1246
|
+
Directly calling external APIs like `fetch` or using impure functions like `Math.random()` within your business logic. This tightly couples your logic to a specific implementation and makes it difficult to test.
|
|
1247
|
+
|
|
1248
|
+
**Rationale:**
|
|
1249
|
+
|
|
1250
|
+
Represent any external dependency or distinct capability—from a database client to a simple UUID generator—as a service.
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
This pattern is the key to testability. It allows you to provide a `Live` implementation in production and a `Test` implementation (returning mock data) in your tests, making your code decoupled and reliable.
|
|
1254
|
+
|
|
1255
|
+
---
|
|
1256
|
+
|
|
1257
|
+
### Create a Testable HTTP Client Service
|
|
1258
|
+
|
|
1259
|
+
**Rule:** Define an HttpClient service with distinct Live and Test layers to enable testable API interactions.
|
|
1260
|
+
|
|
1261
|
+
**Good Example:**
|
|
1262
|
+
|
|
1263
|
+
### 1. Define the Service
|
|
1264
|
+
|
|
1265
|
+
```typescript
|
|
1266
|
+
import { Effect, Data, Layer } from "effect";
|
|
1267
|
+
|
|
1268
|
+
interface HttpErrorType {
|
|
1269
|
+
readonly _tag: "HttpError";
|
|
1270
|
+
readonly error: unknown;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const HttpError = Data.tagged<HttpErrorType>("HttpError");
|
|
1274
|
+
|
|
1275
|
+
interface HttpClientType {
|
|
1276
|
+
readonly get: <T>(url: string) => Effect.Effect<T, HttpErrorType>;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
class HttpClient extends Effect.Service<HttpClientType>()("HttpClient", {
|
|
1280
|
+
sync: () => ({
|
|
1281
|
+
get: <T>(url: string): Effect.Effect<T, HttpErrorType> =>
|
|
1282
|
+
Effect.tryPromise<T>(() =>
|
|
1283
|
+
fetch(url).then((res) => res.json() as T)
|
|
1284
|
+
).pipe(Effect.catchAll((error) => Effect.fail(HttpError({ error })))),
|
|
1285
|
+
}),
|
|
1286
|
+
}) {}
|
|
1287
|
+
|
|
1288
|
+
// Test implementation
|
|
1289
|
+
const TestLayer = Layer.succeed(
|
|
1290
|
+
HttpClient,
|
|
1291
|
+
HttpClient.of({
|
|
1292
|
+
get: <T>(_url: string) => Effect.succeed({ title: "Mock Data" } as T),
|
|
1293
|
+
})
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
// Example usage
|
|
1297
|
+
const program = Effect.gen(function* () {
|
|
1298
|
+
const client = yield* HttpClient;
|
|
1299
|
+
yield* Effect.logInfo("Fetching data...");
|
|
1300
|
+
const data = yield* client.get<{ title: string }>(
|
|
1301
|
+
"https://api.example.com/data"
|
|
1302
|
+
);
|
|
1303
|
+
yield* Effect.logInfo(`Received data: ${JSON.stringify(data)}`);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
// Run with test implementation
|
|
1307
|
+
Effect.runPromise(Effect.provide(program, TestLayer));
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
### 2. Create the Live Implementation
|
|
1311
|
+
|
|
1312
|
+
```typescript
|
|
1313
|
+
import { Effect, Data, Layer } from "effect";
|
|
1314
|
+
|
|
1315
|
+
interface HttpErrorType {
|
|
1316
|
+
readonly _tag: "HttpError";
|
|
1317
|
+
readonly error: unknown;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const HttpError = Data.tagged<HttpErrorType>("HttpError");
|
|
1321
|
+
|
|
1322
|
+
interface HttpClientType {
|
|
1323
|
+
readonly get: <T>(url: string) => Effect.Effect<T, HttpErrorType>;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
class HttpClient extends Effect.Service<HttpClientType>()("HttpClient", {
|
|
1327
|
+
sync: () => ({
|
|
1328
|
+
get: <T>(url: string): Effect.Effect<T, HttpErrorType> =>
|
|
1329
|
+
Effect.tryPromise({
|
|
1330
|
+
try: () => fetch(url).then((res) => res.json()),
|
|
1331
|
+
catch: (error) => HttpError({ error }),
|
|
1332
|
+
}),
|
|
1333
|
+
}),
|
|
1334
|
+
}) {}
|
|
1335
|
+
|
|
1336
|
+
// Test implementation
|
|
1337
|
+
const TestLayer = Layer.succeed(
|
|
1338
|
+
HttpClient,
|
|
1339
|
+
HttpClient.of({
|
|
1340
|
+
get: <T>(_url: string) => Effect.succeed({ title: "Mock Data" } as T),
|
|
1341
|
+
})
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
// Example usage
|
|
1345
|
+
const program = Effect.gen(function* () {
|
|
1346
|
+
const client = yield* HttpClient;
|
|
1347
|
+
yield* Effect.logInfo("Fetching data...");
|
|
1348
|
+
const data = yield* client.get<{ title: string }>(
|
|
1349
|
+
"https://api.example.com/data"
|
|
1350
|
+
);
|
|
1351
|
+
yield* Effect.logInfo(`Received data: ${JSON.stringify(data)}`);
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
// Run with test implementation
|
|
1355
|
+
Effect.runPromise(Effect.provide(program, TestLayer));
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
### 3. Create the Test Implementation
|
|
1359
|
+
|
|
1360
|
+
```typescript
|
|
1361
|
+
// src/services/HttpClientTest.ts
|
|
1362
|
+
import { Effect, Layer } from "effect";
|
|
1363
|
+
import { HttpClient } from "./HttpClient";
|
|
1364
|
+
|
|
1365
|
+
export const HttpClientTest = Layer.succeed(
|
|
1366
|
+
HttpClient,
|
|
1367
|
+
HttpClient.of({
|
|
1368
|
+
get: (url) => Effect.succeed({ mock: "data", url }),
|
|
1369
|
+
})
|
|
1370
|
+
);
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
### 4. Usage in Business Logic
|
|
1374
|
+
|
|
1375
|
+
Your business logic is now clean and only depends on the abstract `HttpClient`.
|
|
1376
|
+
|
|
1377
|
+
```typescript
|
|
1378
|
+
// src/features/User/UserService.ts
|
|
1379
|
+
import { Effect } from "effect";
|
|
1380
|
+
import { HttpClient } from "../../services/HttpClient";
|
|
1381
|
+
|
|
1382
|
+
export const getUserFromApi = (id: number) =>
|
|
1383
|
+
Effect.gen(function* () {
|
|
1384
|
+
const client = yield* HttpClient;
|
|
1385
|
+
const data = yield* client.get(`https://api.example.com/users/${id}`);
|
|
1386
|
+
// ... logic to parse and return user
|
|
1387
|
+
return data;
|
|
1388
|
+
});
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
---
|
|
1392
|
+
|
|
1393
|
+
**Anti-Pattern:**
|
|
1394
|
+
|
|
1395
|
+
Calling `fetch` directly from within your business logic functions. This creates a hard dependency on the global `fetch` API, making the function difficult to test and reuse.
|
|
1396
|
+
|
|
1397
|
+
```typescript
|
|
1398
|
+
import { Effect } from "effect";
|
|
1399
|
+
|
|
1400
|
+
// ❌ WRONG: This function is not easily testable.
|
|
1401
|
+
export const getUserDirectly = (id: number) =>
|
|
1402
|
+
Effect.tryPromise({
|
|
1403
|
+
try: () =>
|
|
1404
|
+
fetch(`https://api.example.com/users/${id}`).then((res) => res.json()),
|
|
1405
|
+
catch: () => "ApiError" as const,
|
|
1406
|
+
});
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
**Rationale:**
|
|
1410
|
+
|
|
1411
|
+
To interact with external APIs, define an `HttpClient` service. Create two separate `Layer` implementations for this service:
|
|
1412
|
+
|
|
1413
|
+
1. **`HttpClientLive`**: The production implementation that uses a real HTTP client (like `fetch`) to make network requests.
|
|
1414
|
+
2. **`HttpClientTest`**: A test implementation that returns mock data, allowing you to test your business logic without making actual network calls.
|
|
1415
|
+
|
|
1416
|
+
---
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
Directly using `fetch` in your business logic makes it nearly impossible to test. Your tests would become slow, flaky (dependent on network conditions), and could have unintended side effects.
|
|
1420
|
+
|
|
1421
|
+
By abstracting the HTTP client into a service, you decouple your application's logic from the specific implementation of how HTTP requests are made. Your business logic depends only on the abstract `HttpClient` interface. In production, you provide the `Live` layer. In tests, you provide the `Test` layer. This makes your tests fast, deterministic, and reliable.
|
|
1422
|
+
|
|
1423
|
+
---
|
|
1424
|
+
|
|
1425
|
+
---
|
|
1426
|
+
|
|
1427
|
+
### Handle Rate Limiting Responses
|
|
1428
|
+
|
|
1429
|
+
**Rule:** Detect 429 responses and automatically retry after the Retry-After period.
|
|
1430
|
+
|
|
1431
|
+
**Good Example:**
|
|
1432
|
+
|
|
1433
|
+
```typescript
|
|
1434
|
+
import { Effect, Schedule, Duration, Data, Ref } from "effect"
|
|
1435
|
+
import { HttpClient, HttpClientResponse } from "@effect/platform"
|
|
1436
|
+
|
|
1437
|
+
// ============================================
|
|
1438
|
+
// 1. Rate limit error type
|
|
1439
|
+
// ============================================
|
|
1440
|
+
|
|
1441
|
+
class RateLimitedError extends Data.TaggedError("RateLimitedError")<{
|
|
1442
|
+
readonly retryAfter: number
|
|
1443
|
+
readonly limit: number | undefined
|
|
1444
|
+
readonly remaining: number | undefined
|
|
1445
|
+
readonly reset: number | undefined
|
|
1446
|
+
}> {}
|
|
1447
|
+
|
|
1448
|
+
// ============================================
|
|
1449
|
+
// 2. Parse rate limit headers
|
|
1450
|
+
// ============================================
|
|
1451
|
+
|
|
1452
|
+
interface RateLimitInfo {
|
|
1453
|
+
readonly retryAfter: number
|
|
1454
|
+
readonly limit?: number
|
|
1455
|
+
readonly remaining?: number
|
|
1456
|
+
readonly reset?: number
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const parseRateLimitHeaders = (headers: Record<string, string>): RateLimitInfo => {
|
|
1460
|
+
// Parse Retry-After (seconds or date)
|
|
1461
|
+
const retryAfterHeader = headers["retry-after"]
|
|
1462
|
+
let retryAfter = 60 // Default 60 seconds
|
|
1463
|
+
|
|
1464
|
+
if (retryAfterHeader) {
|
|
1465
|
+
const parsed = parseInt(retryAfterHeader, 10)
|
|
1466
|
+
if (!isNaN(parsed)) {
|
|
1467
|
+
retryAfter = parsed
|
|
1468
|
+
} else {
|
|
1469
|
+
// Try parsing as date
|
|
1470
|
+
const date = Date.parse(retryAfterHeader)
|
|
1471
|
+
if (!isNaN(date)) {
|
|
1472
|
+
retryAfter = Math.max(0, Math.ceil((date - Date.now()) / 1000))
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return {
|
|
1478
|
+
retryAfter,
|
|
1479
|
+
limit: headers["x-ratelimit-limit"] ? parseInt(headers["x-ratelimit-limit"], 10) : undefined,
|
|
1480
|
+
remaining: headers["x-ratelimit-remaining"] ? parseInt(headers["x-ratelimit-remaining"], 10) : undefined,
|
|
1481
|
+
reset: headers["x-ratelimit-reset"] ? parseInt(headers["x-ratelimit-reset"], 10) : undefined,
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// ============================================
|
|
1486
|
+
// 3. HTTP client with rate limit handling
|
|
1487
|
+
// ============================================
|
|
1488
|
+
|
|
1489
|
+
const makeRateLimitAwareClient = Effect.gen(function* () {
|
|
1490
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
1491
|
+
|
|
1492
|
+
return {
|
|
1493
|
+
get: <T>(url: string) =>
|
|
1494
|
+
Effect.gen(function* () {
|
|
1495
|
+
const response = yield* httpClient.get(url)
|
|
1496
|
+
|
|
1497
|
+
if (response.status === 429) {
|
|
1498
|
+
const rateLimitInfo = parseRateLimitHeaders(response.headers)
|
|
1499
|
+
|
|
1500
|
+
yield* Effect.log(
|
|
1501
|
+
`Rate limited. Retry after ${rateLimitInfo.retryAfter}s`
|
|
1502
|
+
)
|
|
1503
|
+
|
|
1504
|
+
return yield* Effect.fail(new RateLimitedError({
|
|
1505
|
+
retryAfter: rateLimitInfo.retryAfter,
|
|
1506
|
+
limit: rateLimitInfo.limit,
|
|
1507
|
+
remaining: rateLimitInfo.remaining,
|
|
1508
|
+
reset: rateLimitInfo.reset,
|
|
1509
|
+
}))
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
return yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
1513
|
+
}).pipe(
|
|
1514
|
+
Effect.retry({
|
|
1515
|
+
schedule: Schedule.recurWhile<RateLimitedError>(
|
|
1516
|
+
(e) => e._tag === "RateLimitedError"
|
|
1517
|
+
).pipe(
|
|
1518
|
+
Schedule.intersect(Schedule.recurs(3)),
|
|
1519
|
+
Schedule.delayed((_, error) =>
|
|
1520
|
+
Duration.seconds(error.retryAfter + 1) // Add 1s buffer
|
|
1521
|
+
)
|
|
1522
|
+
),
|
|
1523
|
+
while: (error) => error._tag === "RateLimitedError",
|
|
1524
|
+
})
|
|
1525
|
+
),
|
|
1526
|
+
}
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
// ============================================
|
|
1530
|
+
// 4. Proactive rate limiting (client-side)
|
|
1531
|
+
// ============================================
|
|
1532
|
+
|
|
1533
|
+
interface RateLimiter {
|
|
1534
|
+
readonly acquire: () => Effect.Effect<void>
|
|
1535
|
+
readonly release: () => Effect.Effect<void>
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const makeClientRateLimiter = (requestsPerSecond: number) =>
|
|
1539
|
+
Effect.gen(function* () {
|
|
1540
|
+
const tokens = yield* Ref.make(requestsPerSecond)
|
|
1541
|
+
const interval = 1000 / requestsPerSecond
|
|
1542
|
+
|
|
1543
|
+
// Refill tokens periodically
|
|
1544
|
+
yield* Effect.fork(
|
|
1545
|
+
Effect.forever(
|
|
1546
|
+
Effect.gen(function* () {
|
|
1547
|
+
yield* Effect.sleep(Duration.millis(interval))
|
|
1548
|
+
yield* Ref.update(tokens, (n) => Math.min(n + 1, requestsPerSecond))
|
|
1549
|
+
})
|
|
1550
|
+
)
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
const limiter: RateLimiter = {
|
|
1554
|
+
acquire: () =>
|
|
1555
|
+
Effect.gen(function* () {
|
|
1556
|
+
let acquired = false
|
|
1557
|
+
while (!acquired) {
|
|
1558
|
+
const current = yield* Ref.get(tokens)
|
|
1559
|
+
if (current > 0) {
|
|
1560
|
+
yield* Ref.update(tokens, (n) => n - 1)
|
|
1561
|
+
acquired = true
|
|
1562
|
+
} else {
|
|
1563
|
+
yield* Effect.sleep(Duration.millis(interval))
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}),
|
|
1567
|
+
|
|
1568
|
+
release: () => Ref.update(tokens, (n) => Math.min(n + 1, requestsPerSecond)),
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
return limiter
|
|
1572
|
+
})
|
|
1573
|
+
|
|
1574
|
+
// ============================================
|
|
1575
|
+
// 5. Combined client
|
|
1576
|
+
// ============================================
|
|
1577
|
+
|
|
1578
|
+
const makeRobustHttpClient = (requestsPerSecond: number) =>
|
|
1579
|
+
Effect.gen(function* () {
|
|
1580
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
1581
|
+
const rateLimiter = yield* makeClientRateLimiter(requestsPerSecond)
|
|
1582
|
+
|
|
1583
|
+
return {
|
|
1584
|
+
get: <T>(url: string) =>
|
|
1585
|
+
Effect.gen(function* () {
|
|
1586
|
+
// Wait for rate limiter token
|
|
1587
|
+
yield* rateLimiter.acquire()
|
|
1588
|
+
|
|
1589
|
+
const response = yield* httpClient.get(url)
|
|
1590
|
+
|
|
1591
|
+
if (response.status === 429) {
|
|
1592
|
+
const info = parseRateLimitHeaders(response.headers)
|
|
1593
|
+
yield* Effect.log(`Server rate limit hit, waiting ${info.retryAfter}s`)
|
|
1594
|
+
yield* Effect.sleep(Duration.seconds(info.retryAfter))
|
|
1595
|
+
return yield* Effect.fail(new Error("Rate limited"))
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
return yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
1599
|
+
}).pipe(
|
|
1600
|
+
Effect.retry(
|
|
1601
|
+
Schedule.exponential("1 second").pipe(
|
|
1602
|
+
Schedule.intersect(Schedule.recurs(3))
|
|
1603
|
+
)
|
|
1604
|
+
)
|
|
1605
|
+
),
|
|
1606
|
+
}
|
|
1607
|
+
})
|
|
1608
|
+
|
|
1609
|
+
// ============================================
|
|
1610
|
+
// 6. Batch requests to stay under limits
|
|
1611
|
+
// ============================================
|
|
1612
|
+
|
|
1613
|
+
const batchRequests = <T>(
|
|
1614
|
+
urls: string[],
|
|
1615
|
+
requestsPerSecond: number
|
|
1616
|
+
) =>
|
|
1617
|
+
Effect.gen(function* () {
|
|
1618
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
1619
|
+
const results: T[] = []
|
|
1620
|
+
const interval = 1000 / requestsPerSecond
|
|
1621
|
+
|
|
1622
|
+
for (const url of urls) {
|
|
1623
|
+
const response = yield* httpClient.get(url)
|
|
1624
|
+
const data = yield* HttpClientResponse.json(response) as Effect.Effect<T>
|
|
1625
|
+
results.push(data)
|
|
1626
|
+
|
|
1627
|
+
// Wait between requests
|
|
1628
|
+
if (urls.indexOf(url) < urls.length - 1) {
|
|
1629
|
+
yield* Effect.sleep(Duration.millis(interval))
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
return results
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
// ============================================
|
|
1637
|
+
// 7. Usage
|
|
1638
|
+
// ============================================
|
|
1639
|
+
|
|
1640
|
+
const program = Effect.gen(function* () {
|
|
1641
|
+
const client = yield* makeRateLimitAwareClient
|
|
1642
|
+
|
|
1643
|
+
yield* Effect.log("Making rate-limited request...")
|
|
1644
|
+
|
|
1645
|
+
const data = yield* client.get("https://api.example.com/data").pipe(
|
|
1646
|
+
Effect.catchTag("RateLimitedError", (error) =>
|
|
1647
|
+
Effect.gen(function* () {
|
|
1648
|
+
yield* Effect.log(`Gave up after rate limiting. Limit: ${error.limit}`)
|
|
1649
|
+
return { error: "rate_limited" }
|
|
1650
|
+
})
|
|
1651
|
+
)
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
yield* Effect.log(`Result: ${JSON.stringify(data)}`)
|
|
1655
|
+
})
|
|
1656
|
+
```
|
|
1657
|
+
|
|
1658
|
+
**Rationale:**
|
|
1659
|
+
|
|
1660
|
+
Handle HTTP 429 (Too Many Requests) responses by reading the `Retry-After` header and waiting before retrying.
|
|
1661
|
+
|
|
1662
|
+
---
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
Rate limits protect APIs:
|
|
1666
|
+
|
|
1667
|
+
1. **Fair usage** - Share resources among clients
|
|
1668
|
+
2. **Stability** - Prevent overload
|
|
1669
|
+
3. **Quotas** - Enforce billing tiers
|
|
1670
|
+
|
|
1671
|
+
Respecting limits prevents bans and ensures reliable access.
|
|
1672
|
+
|
|
1673
|
+
---
|
|
1674
|
+
|
|
1675
|
+
---
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
## 🟠 Advanced Patterns
|
|
1679
|
+
|
|
1680
|
+
### Build a Basic HTTP Server
|
|
1681
|
+
|
|
1682
|
+
**Rule:** Use a managed Runtime created from a Layer to handle requests in a Node.js HTTP server.
|
|
1683
|
+
|
|
1684
|
+
**Good Example:**
|
|
1685
|
+
|
|
1686
|
+
This example creates a simple server with a `Greeter` service. The server starts, creates a runtime containing the `Greeter`, and then uses that runtime to handle requests.
|
|
1687
|
+
|
|
1688
|
+
```typescript
|
|
1689
|
+
import { HttpServer, HttpServerResponse } from "@effect/platform";
|
|
1690
|
+
import { NodeHttpServer } from "@effect/platform-node";
|
|
1691
|
+
import { Duration, Effect, Fiber, Layer } from "effect";
|
|
1692
|
+
import { createServer } from "node:http";
|
|
1693
|
+
|
|
1694
|
+
// Create a server layer using Node's built-in HTTP server
|
|
1695
|
+
const ServerLive = NodeHttpServer.layer(() => createServer(), { port: 3001 });
|
|
1696
|
+
|
|
1697
|
+
// Define your HTTP app (here responding "Hello World" to every request)
|
|
1698
|
+
const app = Effect.gen(function* () {
|
|
1699
|
+
yield* Effect.logInfo("Received HTTP request");
|
|
1700
|
+
return yield* HttpServerResponse.text("Hello World");
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
const serverLayer = HttpServer.serve(app).pipe(Layer.provide(ServerLive));
|
|
1704
|
+
|
|
1705
|
+
const program = Effect.gen(function* () {
|
|
1706
|
+
yield* Effect.logInfo("Server starting on http://localhost:3001");
|
|
1707
|
+
const fiber = yield* Layer.launch(serverLayer).pipe(Effect.fork);
|
|
1708
|
+
yield* Effect.sleep(Duration.seconds(2));
|
|
1709
|
+
yield* Fiber.interrupt(fiber);
|
|
1710
|
+
yield* Effect.logInfo("Server shutdown complete");
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
Effect.runPromise(program as unknown as Effect.Effect<void, unknown, never>);
|
|
1714
|
+
```
|
|
1715
|
+
|
|
1716
|
+
---
|
|
1717
|
+
|
|
1718
|
+
**Anti-Pattern:**
|
|
1719
|
+
|
|
1720
|
+
Creating a new runtime or rebuilding layers for every single incoming request. This is extremely inefficient and defeats the purpose of Effect's `Layer` system.
|
|
1721
|
+
|
|
1722
|
+
```typescript
|
|
1723
|
+
import * as http from "http";
|
|
1724
|
+
import { Effect, Layer } from "effect";
|
|
1725
|
+
import { GreeterLive } from "./somewhere";
|
|
1726
|
+
|
|
1727
|
+
// ❌ WRONG: This rebuilds the GreeterLive layer on every request.
|
|
1728
|
+
const server = http.createServer((_req, res) => {
|
|
1729
|
+
const requestEffect = Effect.succeed("Hello!").pipe(
|
|
1730
|
+
Effect.provide(GreeterLive) // Providing the layer here is inefficient
|
|
1731
|
+
);
|
|
1732
|
+
Effect.runPromise(requestEffect).then((msg) => res.end(msg));
|
|
1733
|
+
});
|
|
1734
|
+
```
|
|
1735
|
+
|
|
1736
|
+
**Rationale:**
|
|
1737
|
+
|
|
1738
|
+
To build an HTTP server, create a main `AppLayer` that provides all your application's services. Compile this layer into a managed `Runtime` at startup. Use this runtime to execute an `Effect` for each incoming HTTP request, ensuring all logic is handled within the Effect system.
|
|
1739
|
+
|
|
1740
|
+
---
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
This pattern demonstrates the complete lifecycle of a long-running Effect application.
|
|
1744
|
+
|
|
1745
|
+
1. **Setup Phase:** You define all your application's dependencies (database connections, clients, config) in `Layer`s and compose them into a single `AppLayer`.
|
|
1746
|
+
2. **Runtime Creation:** You use `Layer.toRuntime(AppLayer)` to create a highly-optimized `Runtime` object. This is done _once_ when the server starts.
|
|
1747
|
+
3. **Request Handling:** For each incoming request, you create an `Effect` that describes the work to be done (e.g., parse request, call services, create response).
|
|
1748
|
+
4. **Execution:** You use the `Runtime` you created in the setup phase to execute the request-handling `Effect` using `Runtime.runPromise`.
|
|
1749
|
+
|
|
1750
|
+
This architecture ensures that your request handling logic is fully testable, benefits from structured concurrency, and is completely decoupled from the server's setup and infrastructure.
|
|
1751
|
+
|
|
1752
|
+
---
|
|
1753
|
+
|
|
1754
|
+
---
|
|
1755
|
+
|
|
1756
|
+
|