| author | |
| committer | |
| log | e6626ee1643f62c53ca0c2680b46cf11cab9942d |
| tree | 2161785c069f564baffe0e380de3aa97eed9c18c |
| parent | 3213a0c77b4ba8e37a361b884d1035eb8dfd76d1 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 17 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | 17 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; |
| 18 | import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; | 18 | import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay"; |
| 19 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; | 19 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; |
| 20 | 20 | ||
| 21 | import type { SavedRemoteConnection } from "../../lib/connection"; | 21 | import 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 { |
| 89 | 89 | ||
| 90 | function decodedRelayClientError(message: string) { | 90 | function 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 { |
| 22 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; | 22 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; |
| 23 | import { request, runStream } from "@t3tools/client-runtime/rpc"; | 23 | import { request, runStream } from "@t3tools/client-runtime/rpc"; |
| 24 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; | 24 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; |
| 25 | import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; | 25 | import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay"; |
| 26 | 26 | ||
| 27 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; | 27 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; |
| 28 | import { resolveCloudPublicConfig } from "./publicConfig"; | 28 | import { resolveCloudPublicConfig } from "./publicConfig"; |
| ... | @@ -115,10 +115,9 @@ const isEnvironmentCloudApiError = Schema.is( | ... | @@ -115,10 +115,9 @@ const isEnvironmentCloudApiError = Schema.is( |
| 115 | 115 | ||
| 116 | function decodedRelayClientError(message: string) { | 116 | function 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 | } |
| 70 | 70 | ||
| 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. | ||
| 73 | export 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 | */ | ||
| 81 | export 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 of | 91 | // 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 host | 92 | // 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"; |
| 12 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as TestClock from "effect/testing/TestClock"; | 13 | import * as TestClock from "effect/testing/TestClock"; |
| 14 | 14 | ||
| 15 | import { RELAY_GATEWAY_TIMEOUT_MESSAGE, relayRequestFailureDetail } from "./errorPresentation.ts"; | ||
| 15 | import * as ManagedRelay from "./managedRelay.ts"; | 16 | import * as ManagedRelay from "./managedRelay.ts"; |
| 16 | import * as RpcHttp from "../rpc/http.ts"; | 17 | import * as RpcHttp from "../rpc/http.ts"; |
| 17 | import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; | 18 | import { 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 | }); |
| 601 | 602 | ||
| 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< |
| 306 | 308 | ||
| 307 | const isRelayProtectedError = Schema.is(RelayProtectedError); | 309 | const isRelayProtectedError = Schema.is(RelayProtectedError); |
| 308 | 310 | ||
| 311 | const 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. | ||
| 315 | function 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 | |||
| 309 | function relayRequestError(action: ManagedRelayRequestAction) { | 322 | function 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 | } |
| 319 | 334 |