| 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 | 15 | type RelayManagedEndpointProviderKind, |
| 16 | 16 | } from "@t3tools/contracts/relay"; |
| 17 | 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 | 19 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; |
| 20 | 20 | |
| 21 | 21 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| ... | ... | @@ -89,10 +89,9 @@ function withDevCause(message: string, cause: unknown): string { |
| 89 | 89 | |
| 90 | 90 | function decodedRelayClientError(message: string) { |
| 91 | 91 | 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; | |
| 96 | 95 | return new CloudEnvironmentLinkError({ |
| 97 | 96 | message: detail ? `${message}: ${detail}` : message, |
| 98 | 97 | cause, |
apps/web/src/cloud/linkEnvironment.ts+4-5| ... | ... | @@ -22,7 +22,7 @@ import { |
| 22 | 22 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; |
| 23 | 23 | import { request, runStream } from "@t3tools/client-runtime/rpc"; |
| 24 | 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 | 27 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; |
| 28 | 28 | import { resolveCloudPublicConfig } from "./publicConfig"; |
| ... | ... | @@ -115,10 +115,9 @@ const isEnvironmentCloudApiError = Schema.is( |
| 115 | 115 | |
| 116 | 116 | function decodedRelayClientError(message: string) { |
| 117 | 117 | 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; | |
| 122 | 121 | return new CloudEnvironmentLinkError({ |
| 123 | 122 | message: detail ? `${message}: ${detail}` : message, |
| 124 | 123 | cause, |
packages/client-runtime/src/relay/errorPresentation.ts+20| ... | ... | @@ -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 | 91 | // A host with a current build gets a new tunnel on its own within minutes of |
| 72 | 92 | // coming back, which clears this reason. While it is still reported, the host |
| 73 | 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 | 12 | import * as Schema from "effect/Schema"; |
| 13 | 13 | import * as TestClock from "effect/testing/TestClock"; |
| 14 | 14 | |
| 15 | import { RELAY_GATEWAY_TIMEOUT_MESSAGE, relayRequestFailureDetail } from "./errorPresentation.ts"; | |
| 15 | 16 | import * as ManagedRelay from "./managedRelay.ts"; |
| 16 | 17 | import * as RpcHttp from "../rpc/http.ts"; |
| 17 | 18 | import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; |
| ... | ... | @@ -599,6 +600,45 @@ describe("ManagedRelayClient", () => { |
| 599 | 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 | 642 | it.effect("accepts generic DPoP errors from relays without the optional reason", () => { |
| 603 | 643 | const fetchFn = (() => |
| 604 | 644 | Promise.resolve( |
packages/client-runtime/src/relay/managedRelay.ts+16-1| ... | ... | @@ -149,6 +149,8 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedError<ManagedRe |
| 149 | 149 | transportFailed: Schema.optionalKey(Schema.Boolean), |
| 150 | 150 | cause: Schema.Defect(), |
| 151 | 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 | 154 | traceId: Schema.optional(Schema.String), |
| 153 | 155 | }, |
| 154 | 156 | ) { |
| ... | ... | @@ -306,6 +308,17 @@ export class ManagedRelayClient extends Context.Service< |
| 306 | 308 | |
| 307 | 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 | 322 | function relayRequestError(action: ManagedRelayRequestAction) { |
| 310 | 323 | return (cause: RelayHttpRequestError): ManagedRelayClientError => |
| 311 | 324 | new ManagedRelayRequestFailedError({ |
| ... | ... | @@ -313,7 +326,9 @@ function relayRequestError(action: ManagedRelayRequestAction) { |
| 313 | 326 | transportFailed: |
| 314 | 327 | HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError", |
| 315 | 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 |