From e6626ee1643f62c53ca0c2680b46cf11cab9942d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 01:58:09 -0700 Subject: [PATCH] fix(client-runtime): T3 Connect link timeouts explain the relay 504 (#19329) Co-authored-by: Claude Opus 5.5 --- .../src/features/cloud/linkEnvironment.ts | 9 ++--- apps/web/src/cloud/linkEnvironment.ts | 9 ++--- .../src/relay/errorPresentation.ts | 20 ++++++++++ .../src/relay/managedRelay.test.ts | 40 +++++++++++++++++++ .../client-runtime/src/relay/managedRelay.ts | 17 +++++++- 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 1f7d9bb14371aaa44dae42b3973ef46b832fd249..ee5e4a2fc5407a467a681796187d704065d9aeb4 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -15,7 +15,7 @@ import { type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import type { SavedRemoteConnection } from "../../lib/connection"; @@ -89,10 +89,9 @@ function withDevCause(message: string, cause: unknown): string { function decodedRelayClientError(message: string) { return (cause: ManagedRelay.ManagedRelayClientError) => { - const relayError = - cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; - const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; - const detail = relayError ? relayProtectedErrorMessage(relayError) : null; + const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined; + const traceId = failed?.traceId; + const detail = failed ? relayRequestFailureDetail(failed) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 9b6167a4b2fd4fc73082100a29c7ed717aaa54a9..4e1fba7040eac3fb9805be86a4d2111ed43fe74a 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -22,7 +22,7 @@ import { import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay"; import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; import { resolveCloudPublicConfig } from "./publicConfig"; @@ -115,10 +115,9 @@ const isEnvironmentCloudApiError = Schema.is( function decodedRelayClientError(message: string) { return (cause: ManagedRelay.ManagedRelayClientError) => { - const relayError = - cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; - const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; - const detail = relayError ? relayProtectedErrorMessage(relayError) : null; + const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined; + const traceId = failed?.traceId; + const detail = failed ? relayRequestFailureDetail(failed) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index 0a8259ef55bbb8bb10dfbee77cfa9517f94ea96a..28ca912adef6fcaac9320350e056a0c5c908eab4 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -68,6 +68,26 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { } } +// The relay cuts off a request it cannot finish in time with a 504 that no +// endpoint declares, so the client sees only the status. +export const RELAY_GATEWAY_TIMEOUT_MESSAGE = + "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."; + +/** + * User-facing detail for a failed relay request: the relay's typed error, or + * the HTTP status when the relay sent a response the contract does not declare. + * Null when the request got no response. + */ +export function relayRequestFailureDetail(error: { + readonly relayError?: RelayProtectedError | undefined; + readonly status?: number | undefined; +}): string | null { + if (error.relayError) return relayProtectedErrorMessage(error.relayError); + if (error.status === 504) return RELAY_GATEWAY_TIMEOUT_MESSAGE; + if (error.status !== undefined) return `Relay returned an unexpected HTTP ${error.status}.`; + return null; +} + // A host with a current build gets a new tunnel on its own within minutes of // coming back, which clears this reason. While it is still reported, the host // is either still off or running a build too old to do that. diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index b073aa3f404dd6f3ed7e4813eb3010a2074a2edb..1068176e4e5210d1b628e23e7c563978ef7e15f4 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -12,6 +12,7 @@ import * as Tracer from "effect/Tracer"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; +import { RELAY_GATEWAY_TIMEOUT_MESSAGE, relayRequestFailureDetail } from "./errorPresentation.ts"; import * as ManagedRelay from "./managedRelay.ts"; import * as RpcHttp from "../rpc/http.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; @@ -599,6 +600,45 @@ describe("ManagedRelayClient", () => { }).pipe(Effect.provide(layerManagedRelayTest(fetchFn))); }); + it.effect("explains a relay deadline 504 on link and keeps its trace ID", () => { + const fetchFn = (() => + Promise.resolve( + Response.json( + { error: "relay_request_deadline_exceeded" }, + { + status: 504, + headers: { + traceparent: "00-4684d7d140f34f0393d42744d240e207-0baf648c44f4bce6-01", + }, + }, + ), + )) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const error = yield* relayClient + .linkEnvironment({ + clerkToken: "clerk-token", + payload: { + proof: "link-proof", + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayRequestFailedError", + status: 504, + traceId: "4684d7d140f34f0393d42744d240e207", + }); + expect( + error._tag === "ManagedRelayRequestFailedError" ? relayRequestFailureDetail(error) : null, + ).toBe(RELAY_GATEWAY_TIMEOUT_MESSAGE); + }).pipe(Effect.provide(layerManagedRelayTest(fetchFn))); + }); + it.effect("accepts generic DPoP errors from relays without the optional reason", () => { const fetchFn = (() => Promise.resolve( diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index f7316240235624457bf7418b3cb538b66f8219f1..339d28f421e848f8d1f1bfd270b5f5924aa9df4f 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -149,6 +149,8 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedError new ManagedRelayRequestFailedError({ @@ -313,7 +326,9 @@ function relayRequestError(action: ManagedRelayRequestAction) { transportFailed: HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError", cause, - ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}), + ...(isRelayProtectedError(cause) + ? { relayError: cause, traceId: cause.traceId } + : untypedResponseFields(cause)), }); } -- 2.54.0