Open History to see the last commit
1import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { HttpClientRequest, HttpClientResponse } from "effect/http";
4import * as HttpClientError from "effect/http/HttpClientError";
5
6import {
7 filterRelayResponse,
8 relayRequestError,
9 relayRetryAfterMillis,
10 shouldRetryRelayRequest,
11} from "./relayResponse.ts";
12
13const response = (
14 status: number,
15 body: string | Record<string, unknown>,
16 headers?: Record<string, string>,
17) =>
18 HttpClientResponse.fromWeb(
19 HttpClientRequest.post("https://relay.example.test/v1/client/environment-links"),
20 typeof body === "string"
21 ? new Response(body, { status, ...(headers ? { headers } : {}) })
22 : Response.json(body, { status, ...(headers ? { headers } : {}) }),
23 );
24
25it("reports a transport failure category without exposing request or cause details", () => {
26 const error = relayRequestError(
27 new HttpClientError.HttpClientError({
28 reason: new HttpClientError.TransportError({
29 request: HttpClientRequest.post("https://relay.example.test/link?token=private-token"),
30 description: "private transport details",
31 cause: new Error("private cause details"),
32 }),
33 }),
34 );
35
36 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
37 expect(error.message).toContain("TransportError");
38 expect(error.message).toContain("network connection");
39 expect(error.message).not.toContain("relay.example.test");
40 expect(error.message).not.toContain("private");
41 expect(shouldRetryRelayRequest(error)).toBe(true);
42});
43
44it.effect("retries an expired link proof but not a revoked bearer, both 401", () =>
45 Effect.gen(function* () {
46 const expired = yield* filterRelayResponse(
47 response(401, {
48 _tag: "RelayEnvironmentLinkProofExpiredError",
49 code: "environment_link_proof_expired",
50 traceId: "trace-expired",
51 }),
52 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
53 const revoked = yield* filterRelayResponse(
54 response(401, {
55 _tag: "RelayAuthInvalidError",
56 code: "auth_invalid",
57 reason: "invalid_bearer",
58 traceId: "trace-auth",
59 }),
60 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
61
62 expect(expired).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
63 expect(expired.message).toContain("Relay environment link proof expired");
64 expect(expired.message).toContain("date and time");
65 expect(expired.message).toContain("Trace ID: trace-expired");
66 expect(shouldRetryRelayRequest(expired)).toBe(true);
67 expect(revoked).toMatchObject({ rejection: "unauthorized" });
68 expect(shouldRetryRelayRequest(revoked)).toBe(false);
69 }),
70);
71
72it.effect("reports an unrecognized access denial without printing its response body", () =>
73 Effect.gen(function* () {
74 const error = yield* filterRelayResponse(
75 response(403, "<html>private upstream details</html>", {
76 "content-type": "text/html",
77 "cf-ray": "abcdef1234-IAD",
78 }),
79 ).pipe(Effect.flip);
80
81 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" });
82 expect(error.message).toContain("HTTP 403");
83 expect(error.message).toContain("proxy or firewall");
84 expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD");
85 expect(error.message).not.toContain("private upstream details");
86 }),
87);
88
89it.effect("keeps the relay's Retry-After on a retryable failure", () =>
90 Effect.gen(function* () {
91 const seconds = yield* filterRelayResponse(
92 response(503, "paused", { "retry-after": "30" }),
93 ).pipe(Effect.flip);
94 expect(seconds).toMatchObject({ rejection: "unavailable", retryAfterMillis: 30_000 });
95 expect(relayRetryAfterMillis({ cause: { cause: seconds } })).toBe(30_000);
96
97 // The test clock starts at the epoch, so the date is 90 seconds after it.
98 const date = yield* filterRelayResponse(
99 response(429, "slow down", { "retry-after": "Thu, 01 Jan 1970 00:01:30 GMT" }),
100 ).pipe(Effect.flip);
101 expect(date.retryAfterMillis).toBe(90_000);
102
103 const malformed = yield* filterRelayResponse(
104 response(503, "paused", { "retry-after": "soon" }),
105 ).pipe(Effect.flip);
106 expect(malformed.retryAfterMillis).toBeUndefined();
107 }),
108);
109
110it.effect.each([
111 { status: 401, attempts: 1 },
112 { status: 403, attempts: 1 },
113 { status: 429, attempts: 2 },
114 { status: 503, attempts: 2 },
115])("stops rejected startup links but retries temporary failures: $status", ({ status, attempts }) =>
116 Effect.gen(function* () {
117 let requests = 0;
118 const result = yield* Effect.suspend(() => {
119 requests++;
120 return filterRelayResponse(response(requests === 1 ? status : 200, "{}"));
121 }).pipe(
122 Effect.mapError(relayRequestError),
123 Effect.retry({ while: shouldRetryRelayRequest, times: 1 }),
124 Effect.result,
125 );
126 expect(requests).toBe(attempts);
127 expect(result._tag).toBe(attempts === 1 ? "Failure" : "Success");
128 }),
129);