From 62aaa1ecc2c93221ceddfa504d0e4b94a6335acf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 01:51:17 -0700 Subject: [PATCH] fix(client-runtime): T3 Connect link timeouts explain the relay 504 A relay 504 has no typed error body, so the client dropped the status and trace ID and showed only ".../environment-links failed". Keep the HTTP status and the traceparent trace ID for untyped relay responses, and show a specific message for a 504 on web and mobile. Refs #16810 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 1f7d9bb14371..ee5e4a2fc540 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 9b6167a4b2fd..4e1fba7040ea 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 0a8259ef55bb..28ca912adef6 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 b073aa3f404d..1068176e4e52 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 f73162402356..339d28f421e8 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)), }); }