authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 01:58:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 01:58:09-07:00
loge6626ee1643f62c53ca0c2680b46cf11cab9942d
tree2161785c069f564baffe0e380de3aa97eed9c18c
parent3213a0c77b4ba8e37a361b884d1035eb8dfd76d1
signature Signed by PGP key B5690EEEBB952194

fix(client-runtime): T3 Connect link timeouts explain the relay 504 (#19329)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 84 insertions(+), 11 deletions(-)

apps/mobile/src/features/cloud/linkEnvironment.ts+4-5
......@@ -15,7 +15,7 @@ import {
1515 type RelayManagedEndpointProviderKind,
1616} from "@t3tools/contracts/relay";
1717import { findErrorTraceId } from "@t3tools/client-runtime/errors";
18import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";
18import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay";
1919import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
2020
2121import type { SavedRemoteConnection } from "../../lib/connection";
......@@ -89,10 +89,9 @@ function withDevCause(message: string, cause: unknown): string {
8989
9090function decodedRelayClientError(message: string) {
9191 return (cause: ManagedRelay.ManagedRelayClientError) => {
92 const relayError =
93 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;
94 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;
95 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
92 const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined;
93 const traceId = failed?.traceId;
94 const detail = failed ? relayRequestFailureDetail(failed) : null;
9695 return new CloudEnvironmentLinkError({
9796 message: detail ? `${message}: ${detail}` : message,
9897 cause,
apps/web/src/cloud/linkEnvironment.ts+4-5
......@@ -22,7 +22,7 @@ import {
2222import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2323import { request, runStream } from "@t3tools/client-runtime/rpc";
2424import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
25import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";
25import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay";
2626
2727import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";
2828import { resolveCloudPublicConfig } from "./publicConfig";
......@@ -115,10 +115,9 @@ const isEnvironmentCloudApiError = Schema.is(
115115
116116function decodedRelayClientError(message: string) {
117117 return (cause: ManagedRelay.ManagedRelayClientError) => {
118 const relayError =
119 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;
120 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;
121 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
118 const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined;
119 const traceId = failed?.traceId;
120 const detail = failed ? relayRequestFailureDetail(failed) : null;
122121 return new CloudEnvironmentLinkError({
123122 message: detail ? `${message}: ${detail}` : message,
124123 cause,
packages/client-runtime/src/relay/errorPresentation.ts+20
......@@ -68,6 +68,26 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
6868 }
6969}
7070
71// The relay cuts off a request it cannot finish in time with a 504 that no
72// endpoint declares, so the client sees only the status.
73export const RELAY_GATEWAY_TIMEOUT_MESSAGE =
74 "Relay timed out before it finished the request (HTTP 504). Try again in a few minutes. If it keeps failing, copy the trace ID when you report it.";
75
76/**
77 * User-facing detail for a failed relay request: the relay's typed error, or
78 * the HTTP status when the relay sent a response the contract does not declare.
79 * Null when the request got no response.
80 */
81export function relayRequestFailureDetail(error: {
82 readonly relayError?: RelayProtectedError | undefined;
83 readonly status?: number | undefined;
84}): string | null {
85 if (error.relayError) return relayProtectedErrorMessage(error.relayError);
86 if (error.status === 504) return RELAY_GATEWAY_TIMEOUT_MESSAGE;
87 if (error.status !== undefined) return `Relay returned an unexpected HTTP ${error.status}.`;
88 return null;
89}
90
7191// A host with a current build gets a new tunnel on its own within minutes of
7292// coming back, which clears this reason. While it is still reported, the host
7393// is either still off or running a build too old to do that.
packages/client-runtime/src/relay/managedRelay.test.ts+40
......@@ -12,6 +12,7 @@ import * as Tracer from "effect/Tracer";
1212import * as Schema from "effect/Schema";
1313import * as TestClock from "effect/testing/TestClock";
1414
15import { RELAY_GATEWAY_TIMEOUT_MESSAGE, relayRequestFailureDetail } from "./errorPresentation.ts";
1516import * as ManagedRelay from "./managedRelay.ts";
1617import * as RpcHttp from "../rpc/http.ts";
1718import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
......@@ -599,6 +600,45 @@ describe("ManagedRelayClient", () => {
599600 }).pipe(Effect.provide(layerManagedRelayTest(fetchFn)));
600601 });
601602
603 it.effect("explains a relay deadline 504 on link and keeps its trace ID", () => {
604 const fetchFn = (() =>
605 Promise.resolve(
606 Response.json(
607 { error: "relay_request_deadline_exceeded" },
608 {
609 status: 504,
610 headers: {
611 traceparent: "00-4684d7d140f34f0393d42744d240e207-0baf648c44f4bce6-01",
612 },
613 },
614 ),
615 )) satisfies typeof globalThis.fetch;
616
617 return Effect.gen(function* () {
618 const relayClient = yield* ManagedRelay.ManagedRelayClient;
619 const error = yield* relayClient
620 .linkEnvironment({
621 clerkToken: "clerk-token",
622 payload: {
623 proof: "link-proof",
624 notificationsEnabled: true,
625 liveActivitiesEnabled: true,
626 managedTunnelsEnabled: true,
627 },
628 })
629 .pipe(Effect.flip);
630
631 expect(error).toMatchObject({
632 _tag: "ManagedRelayRequestFailedError",
633 status: 504,
634 traceId: "4684d7d140f34f0393d42744d240e207",
635 });
636 expect(
637 error._tag === "ManagedRelayRequestFailedError" ? relayRequestFailureDetail(error) : null,
638 ).toBe(RELAY_GATEWAY_TIMEOUT_MESSAGE);
639 }).pipe(Effect.provide(layerManagedRelayTest(fetchFn)));
640 });
641
602642 it.effect("accepts generic DPoP errors from relays without the optional reason", () => {
603643 const fetchFn = (() =>
604644 Promise.resolve(
packages/client-runtime/src/relay/managedRelay.ts+16-1
......@@ -149,6 +149,8 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedError<ManagedRe
149149 transportFailed: Schema.optionalKey(Schema.Boolean),
150150 cause: Schema.Defect(),
151151 relayError: Schema.optional(RelayProtectedError),
152 // HTTP status of a relay response that did not decode as a typed relay error.
153 status: Schema.optional(Schema.Number),
152154 traceId: Schema.optional(Schema.String),
153155 },
154156) {
......@@ -306,6 +308,17 @@ export class ManagedRelayClient extends Context.Service<
306308
307309const isRelayProtectedError = Schema.is(RelayProtectedError);
308310
311const TRACEPARENT_TRACE_ID = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/i;
312
313// Keeps the status and the relay's traceparent trace ID for a response that
314// carries no typed relay error, such as the relay's request-deadline 504.
315function untypedResponseFields(cause: RelayHttpRequestError) {
316 const response = HttpClientError.isHttpClientError(cause) ? cause.response : undefined;
317 if (!response) return {};
318 const traceId = TRACEPARENT_TRACE_ID.exec(response.headers.traceparent ?? "")?.[1];
319 return { status: response.status, ...(traceId ? { traceId } : {}) };
320}
321
309322function relayRequestError(action: ManagedRelayRequestAction) {
310323 return (cause: RelayHttpRequestError): ManagedRelayClientError =>
311324 new ManagedRelayRequestFailedError({
......@@ -313,7 +326,9 @@ function relayRequestError(action: ManagedRelayRequestAction) {
313326 transportFailed:
314327 HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError",
315328 cause,
316 ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),
329 ...(isRelayProtectedError(cause)
330 ? { relayError: cause, traceId: cause.traceId }
331 : untypedResponseFields(cause)),
317332 });
318333}
319334