Open History to see the last commit
1import type { RelayEnvironmentEndpointFailure } from "@t3tools/contracts/relay";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as HttpClientError from "effect/http/HttpClientError";
6
7export interface EndpointFailureDiagnosis {
8 readonly endpointFailure: RelayEnvironmentEndpointFailure;
9 readonly endpointStatus?: number;
10}
11
12/**
13 * Cloudflare answers a Worker subrequest it cannot route with a plain-text body
14 * such as `error code: 1033`. 1033 means no cloudflared connector is running for
15 * the tunnel; 1016 means the origin hostname did not resolve.
16 */
17export function classifyEndpointResponse(
18 status: number,
19 body: string,
20): RelayEnvironmentEndpointFailure {
21 const cloudflareCode = /error code:\s*(\d{4})/i.exec(body)?.[1];
22 if (cloudflareCode === "1016") return "dns";
23 if (cloudflareCode === "1033") return "tunnel_down";
24 switch (status) {
25 case 530:
26 return cloudflareCode === undefined ? "tunnel_down" : "unknown";
27 // cloudflared answers 502 when nothing listens on the local origin port.
28 case 502:
29 case 503:
30 return "origin_unreachable";
31 case 521:
32 return "refused";
33 case 504:
34 case 522:
35 case 524:
36 return "timeout";
37 case 525:
38 case 526:
39 return "tls";
40 default:
41 // Any other status came from T3 Code itself, in a shape we did not expect.
42 return "invalid_response";
43 }
44}
45
46/** Reads the error code from the transport failure's cause chain, such as undici's `ECONNREFUSED`. */
47export function classifyTransportFailure(cause: unknown): RelayEnvironmentEndpointFailure {
48 const parts: Array<string> = [];
49 for (let current = cause, depth = 0; current instanceof Error && depth < 4; depth++) {
50 const code = "code" in current ? current.code : undefined;
51 parts.push(current.message, typeof code === "string" ? code : "");
52 current = current.cause;
53 }
54 const text = parts.join(" ");
55 if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|\bDNS\b/i.test(text)) return "dns";
56 if (/ECONNREFUSED|refused/i.test(text)) return "refused";
57 if (/CERT|\bTLS\b|\bSSL\b|certificate/i.test(text)) return "tls";
58 if (/ETIMEDOUT|TIMEOUT|timed out/i.test(text)) return "timeout";
59 return "unknown";
60}
61
62const ERROR_BODY_READ_TIMEOUT = Duration.seconds(2);
63
64/** Turns a failed environment request into the cause the client shows the user. */
65export const diagnoseEndpointFailure = Effect.fnUntraced(function* (cause: unknown) {
66 if (!HttpClientError.isHttpClientError(cause)) {
67 return { endpointFailure: "invalid_response" } satisfies EndpointFailureDiagnosis;
68 }
69 const response = cause.response;
70 if (response === undefined) {
71 return {
72 endpointFailure:
73 cause.reason._tag === "TransportError"
74 ? classifyTransportFailure(cause.reason.cause)
75 : "unknown",
76 } satisfies EndpointFailureDiagnosis;
77 }
78 // Only Cloudflare's 530 needs the body to tell a dead tunnel from a DNS error.
79 const body =
80 response.status === 530
81 ? Option.getOrElse(
82 yield* response.text.pipe(
83 Effect.timeoutOption(ERROR_BODY_READ_TIMEOUT),
84 Effect.orElseSucceed(() => Option.none<string>()),
85 ),
86 () => "",
87 )
88 : "";
89 return {
90 endpointFailure: classifyEndpointResponse(response.status, body),
91 endpointStatus: response.status,
92 } satisfies EndpointFailureDiagnosis;
93});