1import { assert, describe, it } from "@effect/vitest"
2import { strictEqual } from "@effect/vitest/utils"
3import { Clock, Duration, Effect, Fiber, Layer, Ref, Schema, Stream } from "effect"
4import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
5import { RateLimiter } from "effect/persistence"
6import { TestClock } from "effect/testing"
7import * as Tracer from "effect/Tracer"
8
9const makeStatusClient = Effect.fnUntraced(function*(status: number) {
10 const attempts = yield* Ref.make(0)
11 const client = HttpClient.make((request) =>
12 Effect.gen(function*() {
13 yield* Ref.update(attempts, (n) => n + 1)
14 return HttpClientResponse.fromWeb(request, new Response(null, { status }))
15 })
16 )
17 return { attempts, client } as const
18})
19
20const makeRedirectClient = Effect.fnUntraced(function*(status: number, location: string | ReadonlyArray<string>) {
21 const locations = typeof location === "string" ? [location] : location
22 const requests = yield* Ref.make<Array<HttpClientRequest.HttpClientRequest>>([])
23 const client = HttpClient.make((request) =>
24 Effect.map(
25 Ref.updateAndGet(requests, (requests) => [...requests, request]),
26 (requests) =>
27 HttpClientResponse.fromWeb(
28 request,
29 requests.length <= locations.length
30 ? new Response(null, { status, headers: { location: locations[requests.length - 1] } })
31 : new Response(null, { status: 200 })
32 )
33 )
34 ).pipe(HttpClient.followRedirects())
35 return { client, requests } as const
36})
37
38const RateLimiterTestLayer = RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory))
39
40describe("HttpClient", () => {
41 it.effect("applies JSON revivers to response schema decoders", () =>
42 Effect.gen(function*() {
43 const makeResponse = () =>
44 HttpClientResponse.fromWeb(
45 HttpClientRequest.get("https://example.com"),
46 new Response("{\"value\":\"original\"}")
47 )
48 const options = {
49 reviver: (key: string, value: unknown) => key === "value" ? "revived" : value
50 }
51
52 assert.deepStrictEqual(
53 yield* HttpClientResponse.schemaBodyJson(Schema.Struct({ value: Schema.String }), options)(makeResponse()),
54 { value: "revived" }
55 )
56 assert.deepStrictEqual(
57 yield* HttpClientResponse.schemaJson(
58 Schema.Struct({ body: Schema.Struct({ value: Schema.String }) }),
59 options
60 )(makeResponse()),
61 { body: { value: "revived" } }
62 )
63 }))
64
65 it.effect("forwards parse options to response schema decoders", () =>
66 Effect.gen(function*() {
67 const response = HttpClientResponse.fromWeb(
68 HttpClientRequest.get("https://example.com"),
69 new Response("{}")
70 )
71 const error = yield* HttpClientResponse.schemaJson(
72 Schema.Struct({
73 body: Schema.Struct({
74 a: Schema.String,
75 b: Schema.String
76 })
77 }),
78 { errors: "all" }
79 )(response).pipe(Effect.flip)
80
81 assert.strictEqual(
82 error.message,
83 `Missing key
84 at ["body"]["a"]
85Missing key
86 at ["body"]["b"]`
87 )
88 }))
89
90 it.effect("forwards parse options to response schema decoders without a body", () =>
91 Effect.gen(function*() {
92 const response = HttpClientResponse.fromWeb(
93 HttpClientRequest.get("https://example.com"),
94 new Response(null)
95 )
96 const error = yield* HttpClientResponse.schemaNoBody(
97 Schema.Struct({
98 headers: Schema.Struct({
99 a: Schema.String,
100 b: Schema.String
101 })
102 }),
103 { errors: "all" }
104 )(response).pipe(Effect.flip)
105
106 assert.strictEqual(
107 error.message,
108 `Missing key
109 at ["headers"]["a"]
110Missing key
111 at ["headers"]["b"]`
112 )
113 }))
114
115 it.effect("preserves a raw large integer through schemaBodyJson reviver context", () =>
116 Effect.gen(function*() {
117 const response = HttpClientResponse.fromWeb(
118 HttpClientRequest.get("https://example.com"),
119 new Response("{\"value\":9223372036854775807}")
120 )
121 const reviver = ((key: string, value: unknown, context: { readonly source: string }) =>
122 key === "value" ? context.source : value) as Parameters<typeof JSON.parse>[1]
123
124 const decoded = yield* HttpClientResponse.schemaBodyJson(
125 Schema.Struct({ value: Schema.String }),
126 { reviver }
127 )(response)
128
129 assert.strictEqual(decoded.value, "9223372036854775807")
130 }))
131
132 it.effect("preserves source bytes after reading response text", () =>
133 Effect.gen(function*() {
134 const response = HttpClientResponse.fromWeb(
135 HttpClientRequest.get("https://example.com"),
136 new Response(new Uint8Array([0xef, 0xbb, 0xbf, 0x61]))
137 )
138 yield* response.text
139 const bytes = new Uint8Array(yield* response.arrayBuffer)
140 assert.deepStrictEqual(Array.from(bytes), [0xef, 0xbb, 0xbf, 0x61])
141 }))
142
143 describe("tracer", () => {
144 it.effect("includes request and response headers by default", () =>
145 Effect.gen(function*() {
146 let clientSpan: Tracer.NativeSpan | undefined
147 const tracer = Tracer.make({
148 span(options) {
149 clientSpan = new Tracer.NativeSpan(options)
150 return clientSpan
151 }
152 })
153 const client = HttpClient.make((request) =>
154 Effect.succeed(
155 HttpClientResponse.fromWeb(
156 request,
157 new Response(null, {
158 headers: { "x-response-default": "response" }
159 })
160 )
161 )
162 )
163
164 yield* client.get("http://test/", {
165 headers: { "x-request-default": "request" }
166 }).pipe(Effect.provideService(Tracer.Tracer, tracer))
167
168 assert(clientSpan !== undefined)
169 assert.strictEqual(clientSpan.attributes.get("http.request.header.x-request-default"), "request")
170 assert.strictEqual(clientSpan.attributes.get("http.response.header.x-response-default"), "response")
171 }))
172
173 it.effect("filters request and response header span attributes", () =>
174 Effect.gen(function*() {
175 let clientSpan: Tracer.NativeSpan | undefined
176 const tracer = Tracer.make({
177 span(options) {
178 clientSpan = new Tracer.NativeSpan(options)
179 return clientSpan
180 }
181 })
182 const client = HttpClient.make((request) =>
183 Effect.succeed(
184 HttpClientResponse.fromWeb(
185 request,
186 new Response(null, {
187 headers: {
188 "x-response-drop": "drop",
189 "x-response-keep": "keep"
190 }
191 })
192 )
193 )
194 )
195
196 yield* client.get("http://test/", {
197 headers: {
198 "x-request-drop": "drop",
199 "x-request-keep": "keep"
200 }
201 }).pipe(
202 Effect.provideService(HttpClient.TracerHeaderFilter, (name) => name.endsWith("-keep")),
203 Effect.provideService(Tracer.Tracer, tracer)
204 )
205
206 assert(clientSpan !== undefined)
207 assert.strictEqual(clientSpan.attributes.get("http.request.header.x-request-drop"), undefined)
208 assert.strictEqual(clientSpan.attributes.get("http.request.header.x-request-keep"), "keep")
209 assert.strictEqual(clientSpan.attributes.get("http.response.header.x-response-drop"), undefined)
210 assert.strictEqual(clientSpan.attributes.get("http.response.header.x-response-keep"), "keep")
211 }))
212
213 it.effect("filters the same header name independently by phase", () =>
214 Effect.gen(function*() {
215 let clientSpan: Tracer.NativeSpan | undefined
216 const tracer = Tracer.make({
217 span(options) {
218 clientSpan = new Tracer.NativeSpan(options)
219 return clientSpan
220 }
221 })
222 const client = HttpClient.make((request) =>
223 Effect.succeed(
224 HttpClientResponse.fromWeb(
225 request,
226 new Response(null, {
227 headers: { "x-phase-filter": "response" }
228 })
229 )
230 )
231 )
232
233 yield* client.get("http://test/", {
234 headers: { "x-phase-filter": "request" }
235 }).pipe(
236 Effect.provideService(HttpClient.TracerHeaderFilter, (_name, phase) => phase === "response"),
237 Effect.provideService(Tracer.Tracer, tracer)
238 )
239
240 assert(clientSpan !== undefined)
241 assert.strictEqual(clientSpan.attributes.get("http.request.header.x-phase-filter"), undefined)
242 assert.strictEqual(clientSpan.attributes.get("http.response.header.x-phase-filter"), "response")
243 }))
244 })
245
246 describe("followRedirects", () => {
247 it.effect("preserves preprocessing error recovery", () =>
248 Effect.gen(function*() {
249 const request = HttpClientRequest.get("https://origin.test/")
250 const recovered = HttpClientResponse.fromWeb(request, new Response(null, { status: 418 }))
251 const client = HttpClient.make((request) =>
252 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })))
253 ).pipe(
254 HttpClient.mapRequestEffect(() => Effect.fail("preprocessing failed")),
255 HttpClient.catch(() => Effect.succeed(recovered)),
256 HttpClient.followRedirects()
257 )
258
259 const response = yield* client.execute(request)
260
261 assert.strictEqual(response, recovered)
262 }))
263
264 it.effect("preserves credential headers on same-origin redirects", () =>
265 Effect.gen(function*() {
266 const { client, requests } = yield* makeRedirectClient(302, "https://origin.test/destination")
267 yield* client.get("https://origin.test/start", {
268 headers: {
269 Authorization: "Bearer secret",
270 Cookie: "session=secret"
271 }
272 })
273
274 const redirected = (yield* Ref.get(requests))[1]
275 assert.strictEqual(redirected.headers.authorization, "Bearer secret")
276 assert.strictEqual(redirected.headers.cookie, "session=secret")
277 }))
278
279 it.effect("strips credential headers on cross-origin redirects", () =>
280 Effect.gen(function*() {
281 const { client, requests } = yield* makeRedirectClient(302, "https://redirect.test/destination")
282 yield* client.get("https://origin.test/start", {
283 headers: {
284 Authorization: "Bearer secret",
285 Cookie: "session=secret",
286 "Proxy-Authorization": "Basic secret",
287 "X-Test": "retained"
288 }
289 })
290
291 const redirected = (yield* Ref.get(requests))[1]
292 assert.isUndefined(redirected.headers.authorization)
293 assert.isUndefined(redirected.headers.cookie)
294 assert.isUndefined(redirected.headers["proxy-authorization"])
295 assert.strictEqual(redirected.headers["x-test"], "retained")
296 }))
297
298 it.effect("strips credential headers on scheme downgrade redirects", () =>
299 Effect.gen(function*() {
300 const { client, requests } = yield* makeRedirectClient(302, "http://origin.test/destination")
301 yield* client.get("https://origin.test/start", {
302 headers: {
303 Authorization: "Bearer secret",
304 Cookie: "session=secret",
305 "Proxy-Authorization": "Basic secret"
306 }
307 })
308
309 const redirected = (yield* Ref.get(requests))[1]
310 assert.isUndefined(redirected.headers.authorization)
311 assert.isUndefined(redirected.headers.cookie)
312 assert.isUndefined(redirected.headers["proxy-authorization"])
313 }))
314
315 it.effect("resolves relative locations against the current hop", () =>
316 Effect.gen(function*() {
317 const { client, requests } = yield* makeRedirectClient(302, ["../next/step", "destination"])
318 yield* client.get("https://origin.test/path/start", {
319 headers: { Authorization: "Bearer secret" }
320 })
321
322 const redirected = (yield* Ref.get(requests))[2]
323 assert.strictEqual(redirected.url, "https://origin.test/next/destination")
324 assert.strictEqual(redirected.headers.authorization, "Bearer secret")
325 }))
326
327 it.effect("switches 303 requests to GET and drops the body", () =>
328 Effect.gen(function*() {
329 const { client, requests } = yield* makeRedirectClient(303, "/destination")
330 yield* HttpClientRequest.post("https://origin.test/start").pipe(
331 HttpClientRequest.bodyText("payload"),
332 client.execute
333 )
334
335 const redirected = (yield* Ref.get(requests))[1]
336 assert.strictEqual(redirected.method, "GET")
337 assert.strictEqual(redirected.body._tag, "Empty")
338 assert.isUndefined(redirected.headers["content-type"])
339 assert.isUndefined(redirected.headers["content-length"])
340 }))
341
342 it.effect.each([301, 302])("switches POST to GET on %s redirects", (status) =>
343 Effect.gen(function*() {
344 const { client, requests } = yield* makeRedirectClient(status, "/destination")
345 yield* HttpClientRequest.post("https://origin.test/start").pipe(
346 HttpClientRequest.bodyText("payload"),
347 client.execute
348 )
349
350 const redirected = (yield* Ref.get(requests))[1]
351 assert.strictEqual(redirected.method, "GET")
352 assert.strictEqual(redirected.body._tag, "Empty")
353 }))
354
355 it.effect.each([301, 302])("preserves non-POST methods on %s redirects", (status) =>
356 Effect.gen(function*() {
357 const { client, requests } = yield* makeRedirectClient(status, "/destination")
358 yield* HttpClientRequest.put("https://origin.test/start").pipe(
359 HttpClientRequest.bodyText("payload"),
360 client.execute
361 )
362
363 const redirected = (yield* Ref.get(requests))[1]
364 assert.strictEqual(redirected.method, "PUT")
365 assert.strictEqual(redirected.body._tag, "Uint8Array")
366 }))
367 })
368
369 describe("retryTransient", () => {
370 it.effect("retries transient responses with retryOn errors-and-responses", () =>
371 Effect.gen(function*() {
372 const { attempts, client } = yield* makeStatusClient(503)
373 const retryClient = client.pipe(HttpClient.retryTransient({ retryOn: "errors-and-responses", times: 2 }))
374 yield* retryClient.get("http://test/").pipe(Effect.ignore)
375 strictEqual(yield* Ref.get(attempts), 3)
376 }))
377
378 it.effect("does not retry transient responses with retryOn errors-only", () =>
379 Effect.gen(function*() {
380 const { attempts, client } = yield* makeStatusClient(503)
381 const retryClient = client.pipe(HttpClient.retryTransient({ retryOn: "errors-only", times: 2 }))
382 yield* retryClient.get("http://test/").pipe(Effect.ignore)
383 strictEqual(yield* Ref.get(attempts), 1)
384 }))
385 })
386
387 describe("stream", () => {
388 it.effect("aborts the request when the response stream ends early", () =>
389 Effect.gen(function*() {
390 let signal: AbortSignal | undefined
391 const client = HttpClient.make((request, _url, requestSignal) =>
392 Effect.sync(() => {
393 signal = requestSignal
394 return HttpClientResponse.fromWeb(
395 request,
396 new Response(
397 new ReadableStream<Uint8Array>({
398 pull(controller) {
399 controller.enqueue(Uint8Array.of(1))
400 }
401 }),
402 { status: 200 }
403 )
404 )
405 })
406 )
407
408 const response = yield* client.get("http://test/")
409 const chunks = yield* response.stream.pipe(
410 Stream.take(5),
411 Stream.runCollect
412 )
413
414 assert.strictEqual(Array.from(chunks).length, 5)
415 assert.isTrue(signal!.aborted)
416 }))
417 })
418
419 describe("withRateLimiter", () => {
420 it.effect("delays requests above the configured limit", () =>
421 Effect.gen(function*() {
422 const attempts = yield* Ref.make(0)
423 const client = HttpClient.make((request) =>
424 Effect.gen(function*() {
425 yield* Ref.update(attempts, (n) => n + 1)
426 return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))
427 })
428 ).pipe(
429 HttpClient.withRateLimiter({
430 limiter: yield* RateLimiter.RateLimiter,
431 key: "test",
432 limit: 1,
433 window: "1 minute"
434 })
435 )
436
437 const fiber = yield* client.get("http://test/").pipe(
438 Effect.andThen(client.get("http://test/")),
439 Effect.forkChild
440 )
441
442 yield* TestClock.adjust("59 seconds")
443 strictEqual(yield* Ref.get(attempts), 1)
444
445 yield* TestClock.adjust("1 second")
446 yield* Fiber.join(fiber)
447
448 strictEqual(yield* Ref.get(attempts), 2)
449 }).pipe(Effect.provide(RateLimiterTestLayer)))
450
451 it.effect("updates limits from response headers by default", () =>
452 Effect.gen(function*() {
453 const attempts = yield* Ref.make(0)
454 const client = HttpClient.make((request) =>
455 Effect.flatMap(
456 Ref.updateAndGet(attempts, (n) => n + 1),
457 (attempt) =>
458 Effect.succeed(
459 HttpClientResponse.fromWeb(
460 request,
461 attempt === 1
462 ? new Response(null, {
463 status: 200,
464 headers: {
465 "x-ratelimit-limit": "1",
466 "x-ratelimit-reset": "60"
467 }
468 })
469 : new Response(null, { status: 200 })
470 )
471 )
472 )
473 ).pipe(
474 HttpClient.withRateLimiter({
475 limiter: yield* RateLimiter.RateLimiter,
476 key: "test",
477 limit: 10,
478 window: "1 minute"
479 })
480 )
481
482 const fiber = yield* client.get("http://test/").pipe(
483 Effect.andThen(client.get("http://test/")),
484 Effect.forkChild
485 )
486
487 yield* TestClock.adjust("5 seconds")
488 strictEqual(yield* Ref.get(attempts), 1)
489
490 yield* TestClock.adjust("1 minute")
491 yield* Fiber.join(fiber)
492 strictEqual(yield* Ref.get(attempts), 2)
493 }).pipe(Effect.provide(RateLimiterTestLayer)))
494
495 it.effect("updates limits from custom response headers", () =>
496 Effect.gen(function*() {
497 const attempts = yield* Ref.make(0)
498 const client = HttpClient.make((request) =>
499 Effect.map(
500 Ref.updateAndGet(attempts, (n) => n + 1),
501 (attempt) =>
502 HttpClientResponse.fromWeb(
503 request,
504 attempt === 1
505 ? new Response(null, {
506 status: 200,
507 headers: {
508 "x-vendor-limit": "1",
509 "x-vendor-reset": "60"
510 }
511 })
512 : new Response(null, { status: 200 })
513 )
514 )
515 ).pipe(
516 HttpClient.withRateLimiter({
517 limiter: yield* RateLimiter.RateLimiter,
518 key: "custom-limit",
519 limit: 10,
520 window: "1 minute",
521 responseHeaders: {
522 limit: "X-Vendor-Limit",
523 reset: "X-Vendor-Reset"
524 }
525 })
526 )
527
528 const fiber = yield* client.get("http://test/").pipe(
529 Effect.andThen(client.get("http://test/")),
530 Effect.forkChild({ startImmediately: true })
531 )
532
533 yield* TestClock.adjust("5 seconds")
534 strictEqual(yield* Ref.get(attempts), 1)
535
536 yield* TestClock.adjust("1 minute")
537 yield* Fiber.join(fiber)
538 strictEqual(yield* Ref.get(attempts), 2)
539 }).pipe(Effect.provide(RateLimiterTestLayer)))
540
541 it.effect("inspects remaining headers to infer updated limits", () =>
542 Effect.gen(function*() {
543 const attempts = yield* Ref.make(0)
544 const limiter = yield* RateLimiter.RateLimiter
545 const client = HttpClient.make((request) =>
546 Effect.map(
547 Ref.updateAndGet(attempts, (n) => n + 1),
548 (attempt) =>
549 HttpClientResponse.fromWeb(
550 request,
551 attempt === 1
552 ? new Response(null, {
553 status: 200,
554 headers: {
555 "x-ratelimit-remaining": "0",
556 "x-ratelimit-reset-after": "60"
557 }
558 })
559 : new Response(null, { status: 200 })
560 )
561 )
562 ).pipe(
563 HttpClient.withRateLimiter({
564 limiter,
565 key: "test",
566 limit: 10,
567 window: "1 minute"
568 })
569 )
570
571 const fiber = yield* client.get("http://test/").pipe(
572 Effect.andThen(client.get("http://test/")),
573 Effect.forkChild({ startImmediately: true })
574 )
575
576 strictEqual(yield* Ref.get(attempts), 1)
577
578 yield* TestClock.adjust("10 seconds")
579 yield* Fiber.join(fiber)
580 strictEqual(yield* Ref.get(attempts), 2)
581 }).pipe(Effect.provide(RateLimiterTestLayer)))
582
583 it.effect("inspects custom remaining and reset-after headers", () =>
584 Effect.gen(function*() {
585 const attempts = yield* Ref.make(0)
586 const limiter = yield* RateLimiter.RateLimiter
587 const client = HttpClient.make((request) =>
588 Effect.map(
589 Ref.updateAndGet(attempts, (n) => n + 1),
590 (attempt) =>
591 HttpClientResponse.fromWeb(
592 request,
593 attempt === 1
594 ? new Response(null, {
595 status: 200,
596 headers: {
597 "x-vendor-remaining": "0",
598 "x-vendor-reset-after": "60"
599 }
600 })
601 : new Response(null, { status: 200 })
602 )
603 )
604 ).pipe(
605 HttpClient.withRateLimiter({
606 limiter,
607 key: "custom-remaining",
608 limit: 10,
609 window: "1 minute",
610 responseHeaders: {
611 remaining: "X-Vendor-Remaining",
612 resetAfter: "X-Vendor-Reset-After"
613 }
614 })
615 )
616
617 const fiber = yield* client.get("http://test/").pipe(
618 Effect.andThen(client.get("http://test/")),
619 Effect.forkChild({ startImmediately: true })
620 )
621
622 strictEqual(yield* Ref.get(attempts), 1)
623
624 yield* TestClock.adjust("10 seconds")
625 yield* Fiber.join(fiber)
626 strictEqual(yield* Ref.get(attempts), 2)
627 }).pipe(Effect.provide(RateLimiterTestLayer)))
628
629 it.effect("uses custom header overrides without falling back to built-in names", () =>
630 Effect.gen(function*() {
631 const attempts = yield* Ref.make(0)
632 const client = HttpClient.make((request) =>
633 Effect.map(
634 Ref.updateAndGet(attempts, (n) => n + 1),
635 (attempt) =>
636 HttpClientResponse.fromWeb(
637 request,
638 attempt === 1
639 ? new Response(null, {
640 status: 200,
641 headers: {
642 "x-ratelimit-limit": "1",
643 "x-ratelimit-reset": "60"
644 }
645 })
646 : new Response(null, { status: 200 })
647 )
648 )
649 ).pipe(
650 HttpClient.withRateLimiter({
651 limiter: yield* RateLimiter.RateLimiter,
652 key: "custom-override",
653 limit: 10,
654 window: "1 minute",
655 responseHeaders: {
656 limit: "X-Vendor-Limit"
657 }
658 })
659 )
660
661 yield* client.get("http://test/")
662 yield* client.get("http://test/")
663
664 strictEqual(yield* Ref.get(attempts), 2)
665 }).pipe(Effect.provide(RateLimiterTestLayer)))
666
667 it.effect("can disable response header inspection", () =>
668 Effect.gen(function*() {
669 const attempts = yield* Ref.make(0)
670 const limiter = yield* RateLimiter.RateLimiter
671 const client = HttpClient.make((request) =>
672 Effect.flatMap(
673 Ref.updateAndGet(attempts, (n) => n + 1),
674 () =>
675 Effect.succeed(
676 HttpClientResponse.fromWeb(
677 request,
678 new Response(null, {
679 status: 200,
680 headers: {
681 "x-ratelimit-limit": "1",
682 "x-ratelimit-reset": "60"
683 }
684 })
685 )
686 )
687 )
688 ).pipe(
689 HttpClient.withRateLimiter({
690 limiter,
691 key: "test",
692 limit: 10,
693 window: "1 minute",
694 disableResponseInspection: true
695 })
696 )
697
698 yield* client.get("http://test/")
699 yield* client.get("http://test/")
700
701 strictEqual(yield* Ref.get(attempts), 2)
702 }).pipe(Effect.provide(RateLimiterTestLayer)))
703
704 it.effect("retries 429 responses through the limiter", () =>
705 Effect.gen(function*() {
706 const attempts = yield* Ref.make(0)
707 const limiter = yield* RateLimiter.RateLimiter
708 const client = HttpClient.make((request) =>
709 Effect.flatMap(
710 Ref.updateAndGet(attempts, (n) => n + 1),
711 (attempt) =>
712 Effect.succeed(
713 HttpClientResponse.fromWeb(
714 request,
715 new Response(null, {
716 status: attempt === 1 ? 429 : 200
717 })
718 )
719 )
720 )
721 ).pipe(
722 HttpClient.withRateLimiter({
723 limiter,
724 key: "test",
725 limit: 1,
726 window: "1 minute",
727 disableResponseInspection: true
728 })
729 )
730
731 const fiber = yield* client.get("http://test/").pipe(Effect.forkChild)
732
733 yield* TestClock.adjust("59 seconds")
734 strictEqual(yield* Ref.get(attempts), 1)
735
736 yield* TestClock.adjust("1 second")
737 const response = yield* Fiber.join(fiber)
738
739 strictEqual(response.status, 200)
740 strictEqual(yield* Ref.get(attempts), 2)
741 }).pipe(Effect.provide(RateLimiterTestLayer)))
742
743 it.effect("retries HttpClientError 429 failures through the limiter", () =>
744 Effect.gen(function*() {
745 const attempts = yield* Ref.make(0)
746 const limiter = yield* RateLimiter.RateLimiter
747 const client = HttpClient.make((request) =>
748 Effect.flatMap(
749 Ref.updateAndGet(attempts, (n) => n + 1),
750 (attempt) =>
751 Effect.succeed(
752 HttpClientResponse.fromWeb(
753 request,
754 new Response(null, {
755 status: attempt === 1 ? 429 : 200
756 })
757 )
758 )
759 )
760 ).pipe(
761 HttpClient.filterStatusOk,
762 HttpClient.withRateLimiter({
763 limiter,
764 key: "test",
765 limit: 1,
766 window: "1 minute",
767 disableResponseInspection: true
768 })
769 )
770
771 const fiber = yield* client.get("http://test/").pipe(Effect.forkChild)
772
773 yield* TestClock.adjust("59 seconds")
774 strictEqual(yield* Ref.get(attempts), 1)
775
776 yield* TestClock.adjust("1 second")
777 const response = yield* Fiber.join(fiber)
778
779 strictEqual(response.status, 200)
780 strictEqual(yield* Ref.get(attempts), 2)
781 }).pipe(Effect.provide(RateLimiterTestLayer)))
782
783 it.effect("bounds automatic 429 response retries", () =>
784 Effect.gen(function*() {
785 const { attempts, client } = yield* makeStatusClient(429)
786 const limitedClient = client.pipe(
787 HttpClient.withRateLimiter({
788 limiter: yield* RateLimiter.RateLimiter,
789 key: "bounded-response",
790 limit: 100,
791 window: "1 minute",
792 times: 2,
793 disableResponseInspection: true
794 })
795 )
796
797 const response = yield* limitedClient.get("http://test/")
798
799 strictEqual(response.status, 429)
800 strictEqual(yield* Ref.get(attempts), 3)
801 }).pipe(Effect.provide(RateLimiterTestLayer)))
802
803 it.effect("bounds automatic HttpClientError 429 retries", () =>
804 Effect.gen(function*() {
805 const { attempts, client } = yield* makeStatusClient(429)
806 const limitedClient = client.pipe(
807 HttpClient.filterStatusOk,
808 HttpClient.withRateLimiter({
809 limiter: yield* RateLimiter.RateLimiter,
810 key: "bounded-error",
811 limit: 100,
812 window: "1 minute",
813 times: 2,
814 disableResponseInspection: true
815 })
816 )
817
818 const error = yield* limitedClient.get("http://test/").pipe(Effect.flip)
819
820 strictEqual(error._tag, "HttpClientError")
821 strictEqual(error.reason._tag, "StatusCodeError")
822 if (error.reason._tag === "StatusCodeError") {
823 strictEqual(error.reason.response.status, 429)
824 }
825 strictEqual(yield* Ref.get(attempts), 3)
826 }).pipe(Effect.provide(RateLimiterTestLayer)))
827
828 it.effect("uses a custom Retry-After header", () =>
829 Effect.gen(function*() {
830 const attempts = yield* Ref.make(0)
831 const client = HttpClient.make((request) =>
832 Effect.map(
833 Ref.updateAndGet(attempts, (n) => n + 1),
834 (attempt) =>
835 HttpClientResponse.fromWeb(
836 request,
837 attempt === 1
838 ? new Response(null, {
839 status: 429,
840 headers: { "x-vendor-retry-after": "10" }
841 })
842 : new Response(null, { status: 200 })
843 )
844 )
845 ).pipe(
846 HttpClient.withRateLimiter({
847 limiter: yield* RateLimiter.RateLimiter,
848 key: "custom-retry-after",
849 limit: 100,
850 window: "1 minute",
851 times: 1,
852 responseHeaders: {
853 retryAfter: "X-Vendor-Retry-After"
854 },
855 disableAdaptiveLearning: true
856 })
857 )
858
859 const fiber = yield* client.get("http://test/").pipe(Effect.forkChild({ startImmediately: true }))
860 strictEqual(yield* Ref.get(attempts), 1)
861
862 yield* TestClock.adjust("9 seconds")
863 strictEqual(yield* Ref.get(attempts), 1)
864
865 yield* TestClock.adjust("1 second")
866 const response = yield* Fiber.join(fiber)
867
868 strictEqual(response.status, 200)
869 strictEqual(yield* Ref.get(attempts), 2)
870 }).pipe(Effect.provide(RateLimiterTestLayer)))
871
872 it.effect("applies adaptive cooldown to requests delayed by the configured limiter", () =>
873 Effect.gen(function*() {
874 const attempts = yield* Ref.make<Array<number>>([])
875 const limiter = yield* RateLimiter.RateLimiter
876 let previous: number | undefined
877 const client = HttpClient.make((request) =>
878 Effect.gen(function*() {
879 const now = yield* Clock.currentTimeMillis
880 const status = previous !== undefined && now - previous < 2_000 ? 429 : 200
881 previous = now
882 yield* Ref.update(attempts, (statuses) => [...statuses, status])
883 return HttpClientResponse.fromWeb(
884 request,
885 status === 429
886 ? new Response(null, { status, headers: { "retry-after": "2" } })
887 : new Response(null, { status })
888 )
889 })
890 ).pipe(
891 HttpClient.withRateLimiter({
892 limiter,
893 key: "adaptive-delayed",
894 limit: 1,
895 window: "1 second"
896 })
897 )
898
899 const fiber = yield* Effect.all([
900 client.get("http://test/1"),
901 client.get("http://test/2"),
902 client.get("http://test/3"),
903 client.get("http://test/4"),
904 client.get("http://test/5")
905 ], { concurrency: "unbounded" }).pipe(Effect.forkChild({ startImmediately: true }))
906
907 strictEqual((yield* Ref.get(attempts)).length, 1)
908
909 yield* TestClock.adjust("1 second")
910 strictEqual((yield* Ref.get(attempts)).length, 2)
911
912 yield* TestClock.adjust("1 second")
913 strictEqual((yield* Ref.get(attempts)).length, 2)
914
915 yield* TestClock.adjust("1 second")
916 assert.deepStrictEqual(yield* Ref.get(attempts), [200, 429, 200, 429])
917
918 yield* TestClock.adjust("6 seconds")
919 yield* Fiber.join(fiber)
920
921 const statuses = yield* Ref.get(attempts)
922 assert.strictEqual(statuses.filter((status) => status === 429).length, 2)
923 }).pipe(Effect.provide(RateLimiterTestLayer)))
924
925 it.effect("coordinates Retry-After cooldown across clients sharing a memory store", () =>
926 Effect.gen(function*() {
927 const attemptsA = yield* Ref.make(0)
928 const attemptsB = yield* Ref.make(0)
929 const limiterA = yield* RateLimiter.make
930 const limiterB = yield* RateLimiter.make
931 const clientA = HttpClient.make((request) =>
932 Effect.map(
933 Ref.updateAndGet(attemptsA, (n) => n + 1),
934 (attempt) =>
935 HttpClientResponse.fromWeb(
936 request,
937 attempt === 1
938 ? new Response(null, {
939 status: 429,
940 headers: { "retry-after": "10" }
941 })
942 : new Response(null, { status: 200 })
943 )
944 )
945 ).pipe(
946 HttpClient.withRateLimiter({
947 limiter: limiterA,
948 key: "shared",
949 limit: 100,
950 window: "1 minute"
951 })
952 )
953 const clientB = HttpClient.make((request) =>
954 Effect.as(
955 Ref.update(attemptsB, (n) => n + 1),
956 HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))
957 )
958 ).pipe(
959 HttpClient.withRateLimiter({
960 limiter: limiterB,
961 key: "shared",
962 limit: 100,
963 window: "1 minute"
964 })
965 )
966
967 const fiberA = yield* clientA.get("http://test/a").pipe(Effect.forkChild({ startImmediately: true }))
968 strictEqual(yield* Ref.get(attemptsA), 1)
969
970 const fiberB = yield* clientB.get("http://test/b").pipe(Effect.forkChild({ startImmediately: true }))
971 yield* TestClock.adjust("9 seconds")
972 strictEqual(yield* Ref.get(attemptsB), 0)
973
974 yield* TestClock.adjust("1 second")
975 yield* Fiber.join(fiberA)
976 yield* Fiber.join(fiberB)
977
978 strictEqual(yield* Ref.get(attemptsA), 2)
979 strictEqual(yield* Ref.get(attemptsB), 1)
980 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
981
982 it.effect("applies Retry-After feedback when automatic retries are disabled", () =>
983 Effect.gen(function*() {
984 const attemptsB = yield* Ref.make(0)
985 const limiterA = yield* RateLimiter.make
986 const limiterB = yield* RateLimiter.make
987 const clientA = HttpClient.make((request) =>
988 Effect.succeed(
989 HttpClientResponse.fromWeb(
990 request,
991 new Response(null, {
992 status: 429,
993 headers: { "retry-after": "10" }
994 })
995 )
996 )
997 ).pipe(
998 HttpClient.withRateLimiter({
999 limiter: limiterA,
1000 key: "terminal-feedback",
1001 limit: 100,
1002 window: "1 minute",
1003 times: 0
1004 })
1005 )
1006 const clientB = HttpClient.make((request) =>
1007 Effect.as(
1008 Ref.update(attemptsB, (n) => n + 1),
1009 HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))
1010 )
1011 ).pipe(
1012 HttpClient.withRateLimiter({
1013 limiter: limiterB,
1014 key: "terminal-feedback",
1015 limit: 100,
1016 window: "1 minute"
1017 })
1018 )
1019
1020 const response = yield* clientA.get("http://test/a")
1021 strictEqual(response.status, 429)
1022
1023 const fiberB = yield* clientB.get("http://test/b").pipe(Effect.forkChild({ startImmediately: true }))
1024 yield* TestClock.adjust("9 seconds")
1025 strictEqual(yield* Ref.get(attemptsB), 0)
1026
1027 yield* TestClock.adjust("1 second")
1028 yield* Fiber.join(fiberB)
1029 strictEqual(yield* Ref.get(attemptsB), 1)
1030 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
1031
1032 it.effect("learns adaptive pacing from Retry-After feedback", () =>
1033 Effect.gen(function*() {
1034 const attempts = yield* Ref.make(0)
1035 const limiter = yield* RateLimiter.make
1036 const client = HttpClient.make((request) =>
1037 Effect.map(
1038 Ref.updateAndGet(attempts, (n) => n + 1),
1039 (attempt) =>
1040 HttpClientResponse.fromWeb(
1041 request,
1042 attempt === 1 || attempt === 4
1043 ? new Response(null, {
1044 status: 429,
1045 headers: { "retry-after": "10" }
1046 })
1047 : new Response(null, { status: 200 })
1048 )
1049 )
1050 ).pipe(
1051 HttpClient.withRateLimiter({
1052 limiter,
1053 key: "learned",
1054 limit: 100,
1055 window: "1 minute"
1056 })
1057 )
1058
1059 const first = yield* client.get("http://test/").pipe(Effect.forkChild({ startImmediately: true }))
1060 strictEqual(yield* Ref.get(attempts), 1)
1061 yield* TestClock.adjust("10 seconds")
1062 yield* Fiber.join(first)
1063 strictEqual(yield* Ref.get(attempts), 2)
1064
1065 yield* client.get("http://test/")
1066 strictEqual(yield* Ref.get(attempts), 3)
1067
1068 const learning = yield* client.get("http://test/").pipe(Effect.forkChild({ startImmediately: true }))
1069 strictEqual(yield* Ref.get(attempts), 4)
1070 yield* TestClock.adjust("10 seconds")
1071 yield* Fiber.join(learning)
1072 strictEqual(yield* Ref.get(attempts), 5)
1073
1074 const paced = yield* client.get("http://test/").pipe(
1075 Effect.andThen(client.get("http://test/")),
1076 Effect.forkChild({ startImmediately: true })
1077 )
1078 strictEqual(yield* Ref.get(attempts), 6)
1079
1080 yield* TestClock.adjust("9 seconds")
1081 strictEqual(yield* Ref.get(attempts), 6)
1082
1083 yield* TestClock.adjust("1 second")
1084 yield* Fiber.join(paced)
1085 strictEqual(yield* Ref.get(attempts), 7)
1086 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
1087
1088 it.effect("applies Retry-After feedback from HttpClientError response failures", () =>
1089 Effect.gen(function*() {
1090 const attemptsA = yield* Ref.make(0)
1091 const attemptsB = yield* Ref.make(0)
1092 const limiterA = yield* RateLimiter.make
1093 const limiterB = yield* RateLimiter.make
1094 const clientA = HttpClient.make((request) =>
1095 Effect.map(
1096 Ref.updateAndGet(attemptsA, (n) => n + 1),
1097 (attempt) =>
1098 HttpClientResponse.fromWeb(
1099 request,
1100 attempt === 1
1101 ? new Response(null, {
1102 status: 429,
1103 headers: { "retry-after": "10" }
1104 })
1105 : new Response(null, { status: 200 })
1106 )
1107 )
1108 ).pipe(
1109 HttpClient.filterStatusOk,
1110 HttpClient.withRateLimiter({
1111 limiter: limiterA,
1112 key: "failure",
1113 limit: 100,
1114 window: "1 minute"
1115 })
1116 )
1117 const clientB = HttpClient.make((request) =>
1118 Effect.as(
1119 Ref.update(attemptsB, (n) => n + 1),
1120 HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))
1121 )
1122 ).pipe(
1123 HttpClient.withRateLimiter({
1124 limiter: limiterB,
1125 key: "failure",
1126 limit: 100,
1127 window: "1 minute"
1128 })
1129 )
1130
1131 const fiberA = yield* clientA.get("http://test/a").pipe(Effect.forkChild({ startImmediately: true }))
1132 strictEqual(yield* Ref.get(attemptsA), 1)
1133
1134 const fiberB = yield* clientB.get("http://test/b").pipe(Effect.forkChild({ startImmediately: true }))
1135 yield* TestClock.adjust("9 seconds")
1136 strictEqual(yield* Ref.get(attemptsB), 0)
1137
1138 yield* TestClock.adjust("1 second")
1139 yield* Fiber.join(fiberA)
1140 yield* Fiber.join(fiberB)
1141
1142 strictEqual(yield* Ref.get(attemptsA), 2)
1143 strictEqual(yield* Ref.get(attemptsB), 1)
1144 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
1145
1146 it.effect("does not send adaptive feedback when response inspection is disabled", () =>
1147 Effect.gen(function*() {
1148 const disabledAttempts = yield* Ref.make(0)
1149 const enabledAttempts = yield* Ref.make(0)
1150 const disabledLimiter = yield* RateLimiter.make
1151 const enabledLimiter = yield* RateLimiter.make
1152 const disabledClient = HttpClient.make((request) =>
1153 Effect.map(
1154 Ref.updateAndGet(disabledAttempts, (n) => n + 1),
1155 (attempt) =>
1156 HttpClientResponse.fromWeb(
1157 request,
1158 attempt === 1
1159 ? new Response(null, {
1160 status: 429,
1161 headers: { "retry-after": "10" }
1162 })
1163 : new Response(null, { status: 200 })
1164 )
1165 )
1166 ).pipe(
1167 HttpClient.withRateLimiter({
1168 limiter: disabledLimiter,
1169 key: "disabled",
1170 limit: 100,
1171 window: "1 minute",
1172 disableResponseInspection: true
1173 })
1174 )
1175 const enabledClient = HttpClient.make((request) =>
1176 Effect.as(
1177 Ref.update(enabledAttempts, (n) => n + 1),
1178 HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))
1179 )
1180 ).pipe(
1181 HttpClient.withRateLimiter({
1182 limiter: enabledLimiter,
1183 key: "disabled",
1184 limit: 100,
1185 window: "1 minute"
1186 })
1187 )
1188
1189 const disabledResponse = yield* disabledClient.get("http://test/disabled")
1190 strictEqual(disabledResponse.status, 200)
1191 strictEqual(yield* Ref.get(disabledAttempts), 2)
1192
1193 const fiber = yield* enabledClient.get("http://test/enabled").pipe(Effect.forkChild({ startImmediately: true }))
1194 strictEqual(yield* Ref.get(enabledAttempts), 1)
1195 yield* Fiber.join(fiber)
1196 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
1197
1198 it.effect("can disable adaptive learning without disabling response inspection", () =>
1199 Effect.gen(function*() {
1200 const disabledAttempts = yield* Ref.make(0)
1201 const enabledAttempts = yield* Ref.make(0)
1202 const disabledLimiter = yield* RateLimiter.make
1203 const enabledLimiter = yield* RateLimiter.make
1204 const disabledClient = HttpClient.make((request) =>
1205 Effect.map(
1206 Ref.updateAndGet(disabledAttempts, (n) => n + 1),
1207 (attempt) =>
1208 HttpClientResponse.fromWeb(
1209 request,
1210 attempt === 1
1211 ? new Response(null, {
1212 status: 429,
1213 headers: { "retry-after": "10" }
1214 })
1215 : new Response(null, { status: 200 })
1216 )
1217 )
1218 ).pipe(
1219 HttpClient.withRateLimiter({
1220 limiter: disabledLimiter,
1221 key: "adaptive-disabled",
1222 limit: 100,
1223 window: "1 minute",
1224 disableAdaptiveLearning: true
1225 })
1226 )
1227 const enabledClient = HttpClient.make((request) =>
1228 Effect.as(
1229 Ref.update(enabledAttempts, (n) => n + 1),
1230 HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))
1231 )
1232 ).pipe(
1233 HttpClient.withRateLimiter({
1234 limiter: enabledLimiter,
1235 key: "adaptive-disabled",
1236 limit: 100,
1237 window: "1 minute"
1238 })
1239 )
1240
1241 const disabled = yield* disabledClient.get("http://test/disabled").pipe(
1242 Effect.forkChild({ startImmediately: true })
1243 )
1244 strictEqual(yield* Ref.get(disabledAttempts), 1)
1245
1246 const enabled = yield* enabledClient.get("http://test/enabled").pipe(
1247 Effect.forkChild({ startImmediately: true })
1248 )
1249 strictEqual(yield* Ref.get(enabledAttempts), 1)
1250 yield* Fiber.join(enabled)
1251
1252 yield* TestClock.adjust("9 seconds")
1253 strictEqual(yield* Ref.get(disabledAttempts), 1)
1254
1255 yield* TestClock.adjust("1 second")
1256 yield* Fiber.join(disabled)
1257 strictEqual(yield* Ref.get(disabledAttempts), 2)
1258 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
1259
1260 it.effect("keeps adaptive Retry-After state isolated by resolved key", () =>
1261 Effect.gen(function*() {
1262 const attemptsA = yield* Ref.make(0)
1263 const attemptsB = yield* Ref.make(0)
1264 const limiter = yield* RateLimiter.make
1265 const clientA = HttpClient.make((request) =>
1266 Effect.map(
1267 Ref.updateAndGet(attemptsA, (n) => n + 1),
1268 (attempt) =>
1269 HttpClientResponse.fromWeb(
1270 request,
1271 attempt === 1
1272 ? new Response(null, {
1273 status: 429,
1274 headers: { "retry-after": "10" }
1275 })
1276 : new Response(null, { status: 200 })
1277 )
1278 )
1279 ).pipe(
1280 HttpClient.withRateLimiter({
1281 limiter,
1282 key: "a",
1283 limit: 100,
1284 window: "1 minute"
1285 })
1286 )
1287 const clientB = HttpClient.make((request) =>
1288 Effect.as(
1289 Ref.update(attemptsB, (n) => n + 1),
1290 HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))
1291 )
1292 ).pipe(
1293 HttpClient.withRateLimiter({
1294 limiter,
1295 key: "b",
1296 limit: 100,
1297 window: "1 minute"
1298 })
1299 )
1300
1301 const fiberA = yield* clientA.get("http://test/a").pipe(Effect.forkChild({ startImmediately: true }))
1302 strictEqual(yield* Ref.get(attemptsA), 1)
1303
1304 const fiberB = yield* clientB.get("http://test/b").pipe(Effect.forkChild({ startImmediately: true }))
1305 strictEqual(yield* Ref.get(attemptsB), 1)
1306 yield* Fiber.join(fiberB)
1307
1308 yield* TestClock.adjust("10 seconds")
1309 yield* Fiber.join(fiberA)
1310 strictEqual(yield* Ref.get(attemptsA), 2)
1311 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
1312
1313 it.effect("prefers explicit RateLimit reset headers over adaptive Retry-After feedback", () =>
1314 Effect.gen(function*() {
1315 const attempts = yield* Ref.make(0)
1316 const limiter = yield* RateLimiter.make
1317 const client = HttpClient.make((request) =>
1318 Effect.map(
1319 Ref.updateAndGet(attempts, (n) => n + 1),
1320 (attempt) =>
1321 HttpClientResponse.fromWeb(
1322 request,
1323 attempt === 1
1324 ? new Response(null, {
1325 status: 429,
1326 headers: {
1327 "retry-after": "60",
1328 "x-ratelimit-limit": "1",
1329 "x-ratelimit-reset-after": "1"
1330 }
1331 })
1332 : new Response(null, { status: 200 })
1333 )
1334 )
1335 ).pipe(
1336 HttpClient.withRateLimiter({
1337 limiter,
1338 key: "explicit",
1339 limit: 100,
1340 window: "1 minute"
1341 })
1342 )
1343
1344 const retry = yield* client.get("http://test/").pipe(Effect.forkChild({ startImmediately: true }))
1345 strictEqual(yield* Ref.get(attempts), 1)
1346
1347 yield* TestClock.adjust(Duration.millis(999))
1348 strictEqual(yield* Ref.get(attempts), 1)
1349
1350 yield* TestClock.adjust(Duration.millis(1))
1351 yield* Fiber.join(retry)
1352 strictEqual(yield* Ref.get(attempts), 2)
1353
1354 const next = yield* client.get("http://test/").pipe(Effect.forkChild({ startImmediately: true }))
1355 yield* TestClock.adjust(Duration.millis(999))
1356 strictEqual(yield* Ref.get(attempts), 2)
1357
1358 yield* TestClock.adjust(Duration.millis(1))
1359 yield* Fiber.join(next)
1360 strictEqual(yield* Ref.get(attempts), 3)
1361 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
1362 })
1363})