Skip to content
Source
Sign inSign up

relayResponse.ts

120 lines5.4 KB

1import { RelayProtectedError } from "@t3tools/contracts/relay";
2import * as Clock from "effect/Clock";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as HttpClientResponse from "effect/http/HttpClientResponse";
7import { isHttpClientError } from "effect/http/HttpClientError";
8​
9/** Only unavailable is retryable. Descriptions expose relay explanations and trace IDs, never request details. */
10export class RelayRequestError extends Schema.TaggedError<RelayRequestError>()(
11 "RelayRequestError",
12 {
13 rejection: Schema.Literals(["unauthorized", "forbidden", "rejected", "unavailable"]),
14 description: Schema.String,
15 /** How long the relay asked callers to wait (`Retry-After`) before retrying. */
16 retryAfterMillis: Schema.optionalKey(Schema.Number),
17 },
18) {
19 override get message(): string {
20 return this.description;
21 }
23​
24const isRelayRequestError = Schema.is(RelayRequestError);
25​
26export function relayRequestError(cause: unknown): RelayRequestError {
27 return isRelayRequestError(cause)
28 ? cause
29 : new RelayRequestError({
30 rejection: "unavailable",
31 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.`,
32 });
34​
35export const shouldRetryRelayRequest = (error: unknown): boolean =>
36 !isRelayRequestError(error) || error.rejection === "unavailable";
37​
38/** The relay answered and asked to be retried later; nothing local is wrong. */
39export const isRelayUnavailable = (error: unknown): boolean =>
40 isRelayRequestError(error) && error.rejection === "unavailable";
41​
42// Retry-After is either delay-seconds or an HTTP date (RFC 9110 §10.2.3).
43const MAX_RELAY_RETRY_AFTER_MILLIS = 15 * 60 * 1000;
44function parseRetryAfterMillis(value: string | undefined, nowMillis: number): number | undefined {
45 if (value === undefined) return undefined;
46 const trimmed = value.trim();
47 const millis = /^\d+$/.test(trimmed) ? Number(trimmed) * 1000 : Date.parse(trimmed) - nowMillis;
48 if (!Number.isFinite(millis) || millis <= 0) return undefined;
49 return Math.min(millis, MAX_RELAY_RETRY_AFTER_MILLIS);
51​
52/** The relay's requested wait, found anywhere in a failure's cause chain. */
53export function relayRetryAfterMillis(error: unknown): number | undefined {
54 for (let current = error, depth = 0; depth < 8; depth++) {
55 if (isRelayRequestError(current)) return current.retryAfterMillis;
56 if (typeof current !== "object" || current === null || !("cause" in current)) return undefined;
57 current = current.cause;
58 }
59 return undefined;
61​
62function recoveryHint(error: RelayProtectedError): string {
63 switch (error._tag) {
64 case "RelayEnvironmentLinkLimitExceededError":
65 return "Unlink an unused environment in T3 Connect, then restart T3 Code on this machine.";
66 case "RelayAuthInvalidError":
67 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.";
68 case "RelayEnvironmentLinkProofExpiredError":
69 case "RelayEnvironmentLinkProofInvalidError":
70 return "Check this machine's date and time, update T3 Code, then restart it.";
71 default:
72 return "Retry when the relay is available. If this continues, include the trace ID when reporting it.";
73 }
75​
76export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(function* (
77 response: HttpClientResponse.HttpClientResponse,
78) {
79 if (response.status >= 200 && response.status < 300) return response;
80 const decoded = yield* HttpClientResponse.schemaBodyJson(RelayProtectedError)(response).pipe(
81 Effect.option,
82 );
83 const ray = response.headers["cf-ray"];
84 const requestId = ray && /^[a-zA-Z0-9-]{1,128}$/.test(ray) ? ` Cloudflare Ray ID: ${ray}.` : "";
85 const description = Option.isSome(decoded)
86 ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.`
87 : `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}`;
88​
89 // Only a schema-decoded expired-proof 401 is retryable for clock skew; every other 401 is permanent.
90 if (
91 response.status === 401 &&
92 Option.isSome(decoded) &&
93 decoded.value._tag === "RelayEnvironmentLinkProofExpiredError"
94 ) {
95 return yield* new RelayRequestError({ rejection: "unavailable", description });
96 }
97 if (response.status === 401) {
98 return yield* new RelayRequestError({ rejection: "unauthorized", description });
99 }
100 if (response.status === 403) {
101 return yield* new RelayRequestError({ rejection: "forbidden", description });
102 }
103 if (
104 response.status >= 400 &&
105 response.status < 500 &&
106 response.status !== 408 &&
107 response.status !== 429
108 ) {
109 return yield* new RelayRequestError({ rejection: "rejected", description });
110 }
111 const retryAfterMillis = parseRetryAfterMillis(
112 response.headers["retry-after"],
113 yield* Clock.currentTimeMillis,
114 );
115 return yield* new RelayRequestError({
116 rejection: "unavailable",
117 description,
118 ...(retryAfterMillis === undefined ? {} : { retryAfterMillis }),
119 });
120});