Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions apps/mobile/src/features/cloud/linkEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 4 additions & 5 deletions apps/web/src/cloud/linkEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/client-runtime/src/relay/errorPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions packages/client-runtime/src/relay/managedRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 16 additions & 1 deletion packages/client-runtime/src/relay/managedRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedError<ManagedRe
transportFailed: Schema.optionalKey(Schema.Boolean),
cause: Schema.Defect(),
relayError: Schema.optional(RelayProtectedError),
// HTTP status of a relay response that did not decode as a typed relay error.
status: Schema.optional(Schema.Number),
traceId: Schema.optional(Schema.String),
},
) {
Expand Down Expand Up @@ -306,14 +308,27 @@ export class ManagedRelayClient extends Context.Service<

const isRelayProtectedError = Schema.is(RelayProtectedError);

const TRACEPARENT_TRACE_ID = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/i;

// Keeps the status and the relay's traceparent trace ID for a response that
// carries no typed relay error, such as the relay's request-deadline 504.
function untypedResponseFields(cause: RelayHttpRequestError) {
const response = HttpClientError.isHttpClientError(cause) ? cause.response : undefined;
if (!response) return {};
const traceId = TRACEPARENT_TRACE_ID.exec(response.headers.traceparent ?? "")?.[1];
return { status: response.status, ...(traceId ? { traceId } : {}) };
}

function relayRequestError(action: ManagedRelayRequestAction) {
return (cause: RelayHttpRequestError): ManagedRelayClientError =>
new ManagedRelayRequestFailedError({
action,
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)),
});
}

Expand Down
Loading