1import { RelayProtectedError } from "@t3tools/contracts/relay";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import * as HttpClientResponse from "effect/http/HttpClientResponse";
6import { isHttpClientError } from "effect/http/HttpClientError";
7
8/**
9 * A relay request that did not succeed. `unavailable` means it may succeed on
10 * retry; the relay refused the other kinds. The description is safe to show:
11 * it carries the relay's own explanation and trace ID, never request details.
12 */
13export class RelayRequestError extends Schema.TaggedError<RelayRequestError>()(
14 "RelayRequestError",
15 {
16 rejection: Schema.Literals(["unauthorized", "forbidden", "rejected", "unavailable"]),
17 description: Schema.String,
18 },
19) {
20 override get message(): string {
21 return this.description;
22 }
23}
24
25const isRelayRequestError = Schema.is(RelayRequestError);
26
27export function relayRequestError(cause: unknown): RelayRequestError {
28 return isRelayRequestError(cause)
29 ? cause
30 : new RelayRequestError({
31 rejection: "unavailable",
32 description: `Could not complete the T3 Connect relay request. ${isHttpClientError(cause) ? `The relay request failed (${cause.reason._tag}).` : "The relay returned an unexpected response."} Check this machine's network connection and relay availability, then retry.`,
33 });
34}
35
36/** Whether a failure may succeed on retry: anything but a relay refusal. */
37export const shouldRetryRelayRequest = (error: unknown): boolean =>
38 !isRelayRequestError(error) || error.rejection === "unavailable";
39
40function recoveryHint(error: RelayProtectedError): string {
41 switch (error._tag) {
42 case "RelayEnvironmentLinkLimitExceededError":
43 return "Unlink an unused environment in T3 Connect, then restart T3 Code on this machine.";
44 case "RelayAuthInvalidError":
45 return "Run `t3 connect login` to check this machine's authorization. If the stored credential was revoked, sign out with `t3 connect logout`, then run `t3 connect` again. Restart T3 Code after signing in.";
46 case "RelayEnvironmentLinkProofExpiredError":
47 case "RelayEnvironmentLinkProofInvalidError":
48 return "Check this machine's date and time, update T3 Code, then restart it.";
49 default:
50 return "Retry when the relay is available. If this continues, include the trace ID when reporting it.";
51 }
52}
53
54/** Preserve relay diagnostics before converting permanent rejections into non-retryable errors. */
55export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(function* (
56 response: HttpClientResponse.HttpClientResponse,
57) {
58 if (response.status >= 200 && response.status < 300) return response;
59 const decoded = yield* HttpClientResponse.schemaBodyJson(RelayProtectedError)(response).pipe(
60 Effect.option,
61 );
62 const ray = response.headers["cf-ray"];
63 const requestId = ray && /^[a-zA-Z0-9-]{1,128}$/.test(ray) ? ` Cloudflare Ray ID: ${ray}.` : "";
64 const description = Option.isSome(decoded)
65 ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.`
66 : `T3 Connect relay returned HTTP ${response.status} without a recognized error response. Check relay access and any proxy or firewall restrictions, then restart T3 Code.${requestId}`;
67
68 if (response.status === 401) {
69 return yield* new RelayRequestError({ rejection: "unauthorized", description });
70 }
71 if (response.status === 403) {
72 return yield* new RelayRequestError({ rejection: "forbidden", description });
73 }
74 if (
75 response.status >= 400 &&
76 response.status < 500 &&
77 response.status !== 408 &&
78 response.status !== 429
79 ) {
80 return yield* new RelayRequestError({ rejection: "rejected", description });
81 }
82 return yield* new RelayRequestError({ rejection: "unavailable", description });
83});