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 {...@@ -15,7 +15,7 @@ import {
15 type RelayManagedEndpointProviderKind,15 type RelayManagedEndpointProviderKind,
16} from "@t3tools/contracts/relay";16} from "@t3tools/contracts/relay";
17import { findErrorTraceId } from "@t3tools/client-runtime/errors";17import { findErrorTraceId } from "@t3tools/client-runtime/errors";
18import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";18import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay";
19import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";19import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
2020
21import type { SavedRemoteConnection } from "../../lib/connection";21import type { SavedRemoteConnection } from "../../lib/connection";
...@@ -89,10 +89,9 @@ function withDevCause(message: string, cause: unknown): string {...@@ -89,10 +89,9 @@ function withDevCause(message: string, cause: unknown): string {
8989
90function decodedRelayClientError(message: string) {90function decodedRelayClientError(message: string) {
91 return (cause: ManagedRelay.ManagedRelayClientError) => {91 return (cause: ManagedRelay.ManagedRelayClientError) => {
92 const relayError =92 const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined;
93 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;93 const traceId = failed?.traceId;
94 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;94 const detail = failed ? relayRequestFailureDetail(failed) : null;
95 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
96 return new CloudEnvironmentLinkError({95 return new CloudEnvironmentLinkError({
97 message: detail ? `${message}: ${detail}` : message,96 message: detail ? `${message}: ${detail}` : message,
98 cause,97 cause,
apps/web/src/cloud/linkEnvironment.ts+4-5
...@@ -22,7 +22,7 @@ import {...@@ -22,7 +22,7 @@ import {
22import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";22import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
23import { request, runStream } from "@t3tools/client-runtime/rpc";23import { request, runStream } from "@t3tools/client-runtime/rpc";
24import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";24import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
25import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";25import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay";
2626
27import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";27import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";
28import { resolveCloudPublicConfig } from "./publicConfig";28import { resolveCloudPublicConfig } from "./publicConfig";
...@@ -115,10 +115,9 @@ const isEnvironmentCloudApiError = Schema.is(...@@ -115,10 +115,9 @@ const isEnvironmentCloudApiError = Schema.is(
115115
116function decodedRelayClientError(message: string) {116function decodedRelayClientError(message: string) {
117 return (cause: ManagedRelay.ManagedRelayClientError) => {117 return (cause: ManagedRelay.ManagedRelayClientError) => {
118 const relayError =118 const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined;
119 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;119 const traceId = failed?.traceId;
120 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;120 const detail = failed ? relayRequestFailureDetail(failed) : null;
121 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
122 return new CloudEnvironmentLinkError({121 return new CloudEnvironmentLinkError({
123 message: detail ? `${message}: ${detail}` : message,122 message: detail ? `${message}: ${detail}` : message,
124 cause,123 cause,
packages/client-runtime/src/relay/errorPresentation.ts+20
...@@ -68,6 +68,26 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {...@@ -68,6 +68,26 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
68 }68 }
69}69}
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
71// A host with a current build gets a new tunnel on its own within minutes of91// A host with a current build gets a new tunnel on its own within minutes of
72// coming back, which clears this reason. While it is still reported, the host92// coming back, which clears this reason. While it is still reported, the host
73// is either still off or running a build too old to do that.93// 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";...@@ -12,6 +12,7 @@ import * as Tracer from "effect/Tracer";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";13import * as TestClock from "effect/testing/TestClock";
1414
15import { RELAY_GATEWAY_TIMEOUT_MESSAGE, relayRequestFailureDetail } from "./errorPresentation.ts";
15import * as ManagedRelay from "./managedRelay.ts";16import * as ManagedRelay from "./managedRelay.ts";
16import * as RpcHttp from "../rpc/http.ts";17import * as RpcHttp from "../rpc/http.ts";
17import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";18import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
...@@ -599,6 +600,45 @@ describe("ManagedRelayClient", () => {...@@ -599,6 +600,45 @@ describe("ManagedRelayClient", () => {
599 }).pipe(Effect.provide(layerManagedRelayTest(fetchFn)));600 }).pipe(Effect.provide(layerManagedRelayTest(fetchFn)));
600 });601 });
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
602 it.effect("accepts generic DPoP errors from relays without the optional reason", () => {642 it.effect("accepts generic DPoP errors from relays without the optional reason", () => {
603 const fetchFn = (() =>643 const fetchFn = (() =>
604 Promise.resolve(644 Promise.resolve(
packages/client-runtime/src/relay/managedRelay.ts+16-1
...@@ -149,6 +149,8 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedError<ManagedRe...@@ -149,6 +149,8 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedError<ManagedRe
149 transportFailed: Schema.optionalKey(Schema.Boolean),149 transportFailed: Schema.optionalKey(Schema.Boolean),
150 cause: Schema.Defect(),150 cause: Schema.Defect(),
151 relayError: Schema.optional(RelayProtectedError),151 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),
152 traceId: Schema.optional(Schema.String),154 traceId: Schema.optional(Schema.String),
153 },155 },
154) {156) {
...@@ -306,6 +308,17 @@ export class ManagedRelayClient extends Context.Service<...@@ -306,6 +308,17 @@ export class ManagedRelayClient extends Context.Service<
306308
307const isRelayProtectedError = Schema.is(RelayProtectedError);309const 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
309function relayRequestError(action: ManagedRelayRequestAction) {322function relayRequestError(action: ManagedRelayRequestAction) {
310 return (cause: RelayHttpRequestError): ManagedRelayClientError =>323 return (cause: RelayHttpRequestError): ManagedRelayClientError =>
311 new ManagedRelayRequestFailedError({324 new ManagedRelayRequestFailedError({
...@@ -313,7 +326,9 @@ function relayRequestError(action: ManagedRelayRequestAction) {...@@ -313,7 +326,9 @@ function relayRequestError(action: ManagedRelayRequestAction) {
313 transportFailed:326 transportFailed:
314 HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError",327 HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError",
315 cause,328 cause,
316 ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),329 ...(isRelayProtectedError(cause)
330 ? { relayError: cause, traceId: cause.traceId }
331 : untypedResponseFields(cause)),
317 });332 });
318}333}
319334