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 shouldRetryRelayRequest,
10} from "./relayResponse.ts";
11
12const response = (
13 status: number,
14 body: string | Record<string, unknown>,
15 headers?: Record<string, string>,
16) =>
17 HttpClientResponse.fromWeb(
18 HttpClientRequest.post("https://relay.example.test/v1/client/environment-links"),
19 typeof body === "string"
20 ? new Response(body, { status, ...(headers ? { headers } : {}) })
21 : Response.json(body, { status, ...(headers ? { headers } : {}) }),
22 );
23
24it("reports a transport failure category without exposing request or cause details", () => {
25 const error = relayRequestError(
26 new HttpClientError.HttpClientError({
27 reason: new HttpClientError.TransportError({
28 request: HttpClientRequest.post("https://relay.example.test/link?token=private-token"),
29 description: "private transport details",
30 cause: new Error("private cause details"),
31 }),
32 }),
33 );
34
35 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
36 expect(error.message).toContain("TransportError");
37 expect(error.message).toContain("network connection");
38 expect(error.message).not.toContain("relay.example.test");
39 expect(error.message).not.toContain("private");
40 expect(shouldRetryRelayRequest(error)).toBe(true);
41});
42
43it.effect("reports the tunnel limit and relay trace instead of a generic 403", () =>
44 Effect.gen(function* () {
45 const error = yield* filterRelayResponse(
46 response(403, {
47 _tag: "RelayEnvironmentLinkLimitExceededError",
48 code: "environment_link_limit_exceeded",
49 maxTunnels: 3,
50 traceId: "trace-limit",
51 }),
52 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
53
54 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" });
55 expect(error.message).toContain("at most 3 tunnels");
56 expect(error.message).toContain("Unlink an unused environment");
57 expect(error.message).toContain("Trace ID: trace-limit");
58 }),
59);
60
61it.effect("makes revoked authorization actionable and non-retryable", () =>
62 Effect.gen(function* () {
63 const error = yield* filterRelayResponse(
64 response(401, {
65 _tag: "RelayAuthInvalidError",
66 code: "auth_invalid",
67 reason: "invalid_bearer",
68 traceId: "trace-auth",
69 }),
70 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
71
72 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" });
73 expect(error.message).toContain("invalid_bearer");
74 expect(error.message).toContain("t3 connect login");
75 expect(error.message).toContain("Trace ID: trace-auth");
76 }),
77);
78
79it.effect("reports an unrecognized access denial without printing its response body", () =>
80 Effect.gen(function* () {
81 const error = yield* filterRelayResponse(
82 response(403, "<html>private upstream details</html>", {
83 "content-type": "text/html",
84 "cf-ray": "abcdef1234-IAD",
85 }),
86 ).pipe(Effect.flip);
87
88 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" });
89 expect(error.message).toContain("HTTP 403");
90 expect(error.message).toContain("proxy or firewall");
91 expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD");
92 expect(error.message).not.toContain("private upstream details");
93 }),
94);
95
96it.effect.each([408, 429, 500, 502, 503, 504])(
97 "keeps transient HTTP %s failures retryable",
98 (status) =>
99 Effect.gen(function* () {
100 const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip);
101 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
102 expect(error.message).toContain(`HTTP ${status}`);
103 }),
104);
105
106it.effect.each([
107 { status: 401, attempts: 1 },
108 { status: 403, attempts: 1 },
109 { status: 429, attempts: 2 },
110 { status: 503, attempts: 2 },
111])("stops rejected startup links but retries temporary failures: $status", ({ status, attempts }) =>
112 Effect.gen(function* () {
113 let requests = 0;
114 const result = yield* Effect.suspend(() => {
115 requests++;
116 return filterRelayResponse(response(requests === 1 ? status : 200, "{}"));
117 }).pipe(
118 Effect.mapError(relayRequestError),
119 Effect.retry({ while: shouldRetryRelayRequest, times: 1 }),
120 Effect.result,
121 );
122 expect(requests).toBe(attempts);
123 expect(result._tag).toBe(attempts === 1 ? "Failure" : "Success");
124 }),
125);
126
127it.effect("keeps the relay failure reason and trace when tunnel cleanup fails", () =>
128 Effect.gen(function* () {
129 const error = yield* filterRelayResponse(
130 response(500, {
131 _tag: "RelayInternalError",
132 code: "internal_error",
133 reason: "upstream_unavailable",
134 traceId: "trace-cleanup",
135 }),
136 ).pipe(Effect.flip);
137
138 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" });
139 expect(error.message).toContain("upstream_unavailable");
140 expect(error.message).toContain("Trace ID: trace-cleanup");
141 }),
142);
143
144it.effect("leaves successful response bodies available to their decoder", () =>
145 Effect.gen(function* () {
146 const result = yield* filterRelayResponse(response(200, '{"ok":true}'));
147 expect(yield* result.json).toEqual({ ok: true });
148 }),
149);