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
4 changes: 3 additions & 1 deletion apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1524,7 +1524,9 @@ function SavedBackendListRow({
) : null}
{environment.connection.error && !resumingServerUpdate ? (
<p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
<span className="truncate">{connectionStatusText(environment.connection)}</span>
<span className="min-w-0 break-words">
{connectionStatusText(environment.connection)}
</span>
{errorTraceId ? (
<button
type="button"
Expand Down
7 changes: 4 additions & 3 deletions packages/client-runtime/src/authorization/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemp

const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (
httpBaseUrl: string,
connectionMethod: ClientConnectionMethod,
) {
return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe(
Effect.mapError(mapRemoteEnvironmentError),
Effect.mapError((error) => mapRemoteEnvironmentError(error, connectionMethod)),
);
});

Expand Down Expand Up @@ -119,7 +120,7 @@ export const make = Effect.gen(function* () {
cachedDescriptor.validatedAtEpochMs + BEARER_DESCRIPTOR_CACHE_TTL_MS > now;
const descriptor = canReuseDescriptor
? cachedDescriptor.descriptor
: yield* fetchDescriptor(input.httpBaseUrl).pipe(
: yield* fetchDescriptor(input.httpBaseUrl, input.connectionMethod).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
);
if (descriptor.environmentId !== input.expectedEnvironmentId) {
Expand Down Expand Up @@ -252,7 +253,7 @@ export const make = Effect.gen(function* () {
"connection.remote_token_cache": "miss",
});
const bootstrap = yield* input.obtainBootstrap;
const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe(
const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl, "relay").pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.withSpan("environment.authorization.descriptor"),
);
Expand Down
60 changes: 58 additions & 2 deletions packages/client-runtime/src/connection/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,40 @@
import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
import {
RelayAuthInvalidError,
RelayEnvironmentEndpointTimedOutError,
} from "@t3tools/contracts/relay";
import { describe, expect, it } from "@effect/vitest";

import { mapManagedRelayError, mapRemoteDpopEnvironmentError } from "./errors.ts";
import {
mapManagedRelayError,
mapRemoteDpopEnvironmentError,
mapRemoteEnvironmentError,
} from "./errors.ts";
import { DPOP_RETRY_HINT, DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts";
import { ManagedRelayRequestFailedError } from "../relay/managedRelay.ts";
import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
import { RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthTimeoutError } from "../rpc/http.ts";

describe("mapManagedRelayError", () => {
it("keeps a timeout reported by the relay distinct from a local network failure", () => {
const relayError = new RelayEnvironmentEndpointTimedOutError({
code: "environment_endpoint_timed_out",
traceId: "trace-server-timeout",
});
const mapped = mapManagedRelayError(
new ManagedRelayRequestFailedError({
action: "connect relay environment",
cause: relayError,
relayError,
}),
);
expect(mapped).toMatchObject({
reason: "timeout",
detail: "Relay timed out while contacting the environment endpoint.",
traceId: "trace-server-timeout",
});
});

it("presents clock skew as one possible cause for a generic DPoP error", () => {
const mapped = mapManagedRelayError(
new ManagedRelayRequestFailedError({
Expand Down Expand Up @@ -48,6 +76,34 @@ describe("mapManagedRelayError", () => {
});

describe("mapRemoteDpopEnvironmentError", () => {
it("keeps relay descriptor auth failures distinct from DPoP proof failures", () => {
const error = new EnvironmentAuthInvalidError({
code: "auth_invalid",
reason: "invalid_credential",
traceId: "trace-descriptor",
});
expect(mapRemoteEnvironmentError(error, "relay").message).toBe(
"The environment credential is invalid.",
);
expect(mapRemoteDpopEnvironmentError(error).message).toBe(
`The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`,
);
});

it.each([
new RemoteEnvironmentAuthFetchError({
message: "Failed to fetch remote environment endpoint.",
cause: new TypeError("Failed to fetch"),
}),
new RemoteEnvironmentAuthTimeoutError("https://environment.example.test", 10_000),
])("suggests another network when the relay endpoint cannot be reached: $_tag", (error) => {
const mapped = mapRemoteDpopEnvironmentError(error);
expect(mapped).toMatchObject({
_tag: "ConnectionTransientError",
detail: `${error.message} ${NETWORK_BLOCKING_HINT}`,
});
});

it("does not present a generic environment auth error as confirmed clock skew", () => {
const mapped = mapRemoteDpopEnvironmentError(
new EnvironmentAuthInvalidError({
Expand Down
11 changes: 7 additions & 4 deletions packages/client-runtime/src/connection/errors.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { ClientConnectionMethod, EnvironmentId } from "@t3tools/contracts";
import type { RelayProtectedError } from "@t3tools/contracts/relay";
import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";
import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
import {
ConnectionBlockedError,
type ConnectionAttemptError,
Expand Down Expand Up @@ -113,7 +114,9 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection

export function mapRemoteEnvironmentError(
error: RemoteEnvironmentAuthError,
connectionMethod: ClientConnectionMethod = "direct",
): ConnectionAttemptError {
const networkHint = connectionMethod === "relay" ? ` ${NETWORK_BLOCKING_HINT}` : "";
switch (error._tag) {
case "EnvironmentAuthInvalidError":
return new ConnectionBlockedError({
Expand Down Expand Up @@ -146,12 +149,12 @@ export function mapRemoteEnvironmentError(
case "RemoteEnvironmentAuthTimeoutError":
return new ConnectionTransientError({
reason: "timeout",
detail: error.message,
detail: `${error.message}${networkHint}`,
});
case "RemoteEnvironmentAuthFetchError":
return new ConnectionTransientError({
reason: "network",
detail: error.message,
detail: `${error.message}${networkHint}`,
});
case "EnvironmentInternalError":
return new ConnectionTransientError({
Expand Down Expand Up @@ -185,5 +188,5 @@ export function mapRemoteDpopEnvironmentError(
traceId: error.traceId,
});
}
return mapRemoteEnvironmentError(error);
return mapRemoteEnvironmentError(error, "relay");
}
32 changes: 31 additions & 1 deletion packages/client-runtime/src/connection/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import * as RpcSession from "../rpc/session.ts";
import * as EnvironmentSupervisor from "./supervisor.ts";
import * as ConnectionWakeups from "./wakeups.ts";
import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";

const TARGET = new PrimaryConnectionTarget({
environmentId: EnvironmentId.make("environment-1"),
Expand Down Expand Up @@ -467,6 +468,35 @@ describe("EnvironmentSupervisor", () => {
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect(
"shows a network hint for a stalled relay connection and clears it after recovery",
() =>
Effect.gen(function* () {
const harness = yield* makeHarness({
prepare: (attempt) =>
attempt === 1 ? Effect.never : Effect.succeed(PREPARED_CONNECTION),
});
const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
initiallyDesired: true,
}).pipe(Effect.provide(harness.dependencies));

yield* awaitState(supervisor.state, (state) => state.phase === "connecting");
yield* TestClock.adjust("15 seconds");
const failed = yield* awaitState(supervisor.state, (state) => state.phase === "backoff");
expect(failed.lastFailure?.message).toBe(
`Test environment did not respond during connection setup. ${NETWORK_BLOCKING_HINT}`,
);

yield* TestClock.adjust("3 seconds");
const recovered = yield* awaitState(
supervisor.state,
(state) => state.phase === "connected",
);
expect(recovered.lastFailure).toBeNull();
expect(yield* Ref.get(harness.prepareCount)).toBe(2);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("converts unexpected driver defects into retryable failures", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
Expand All @@ -475,7 +505,7 @@ describe("EnvironmentSupervisor", () => {
? Effect.die(new Error("Native transport defect."))
: Effect.succeed(PREPARED_CONNECTION),
});
const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
initiallyDesired: true,
}).pipe(Effect.provide(harness.dependencies));

Expand Down
8 changes: 6 additions & 2 deletions packages/client-runtime/src/connection/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "./model.ts";
import * as RpcSession from "../rpc/session.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
import * as ConnectionWakeups from "./wakeups.ts";

const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const;
Expand Down Expand Up @@ -241,6 +242,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
| ConnectionWakeups.ConnectionWakeups
> {
const target = entry.target;
const setupTimeoutDetail = `${target.label} did not respond during connection setup.${
target._tag === "RelayConnectionTarget" ? ` ${NETWORK_BLOCKING_HINT}` : ""
}`;
yield* annotateTarget(target);

const connectivity = yield* Connectivity.Connectivity;
Expand Down Expand Up @@ -626,7 +630,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
} else {
replacementError = new ConnectionTransientError({
reason: "timeout",
detail: `${target.label} did not respond during connection setup.`,
detail: setupTimeoutDetail,
});
}

Expand Down Expand Up @@ -708,7 +712,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
failure: {
error: new ConnectionTransientError({
reason: "timeout",
detail: `${target.label} did not respond during connection setup.`,
detail: setupTimeoutDetail,
}),
attemptSpan: Option.none(),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/src/errors/network.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// A failed request cannot distinguish filtering from an outage. Keep this a
// possible cause, and suggest a way to check without changing server settings.
export const NETWORK_BLOCKING_HINT =
"Your DNS or firewall may be blocking T3 Connect. Try another network, such as a phone hotspot.";
3 changes: 2 additions & 1 deletion packages/client-runtime/src/relay/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as Connectivity from "../connection/connectivity.ts";
import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts";
import * as ConnectionWakeups from "../connection/wakeups.ts";
import * as RelayEnvironmentDiscovery from "./discovery.ts";
import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";

const environments = [
{
Expand Down Expand Up @@ -305,7 +306,7 @@ describe("RelayEnvironmentDiscovery", () => {
expect(Option.getOrThrow(state.error)).toMatchObject({
_tag: "ConnectionTransientError",
reason: "timeout",
message: "Relay environment listing timed out.",
message: `Relay environment listing timed out. ${NETWORK_BLOCKING_HINT}`,
});
}).pipe(Effect.provide(layer));
}),
Expand Down
40 changes: 39 additions & 1 deletion packages/client-runtime/src/relay/managedRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Tracer from "effect/Tracer";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";

import * as ManagedRelay from "./managedRelay.ts";
import { remoteHttpClientLayer } from "../rpc/http.ts";
import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";

const encodeRelayError = Schema.encodeEffect(ManagedRelay.ManagedRelayClientError);
const decodeRelayError = Schema.decodeUnknownEffect(ManagedRelay.ManagedRelayClientError);

function managedRelayTestLayer(
fetchFn: typeof globalThis.fetch,
Expand Down Expand Up @@ -529,11 +534,44 @@ describe("ManagedRelayClient", () => {
_tag: "ManagedRelayRequestTimeoutError",
activity: "Relay environment listing",
timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
message: "Relay environment listing timed out.",
message: `Relay environment listing timed out. ${NETWORK_BLOCKING_HINT}`,
});
}).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn))));
});

it.effect("suggests checking network filtering when fetch fails without a response", () => {
const fetchFn = (() =>
Promise.reject(new TypeError("Failed to fetch"))) satisfies typeof globalThis.fetch;
return Effect.gen(function* () {
const relayClient = yield* ManagedRelay.ManagedRelayClient;
const error = yield* relayClient
.listEnvironments({ clerkToken: "clerk-token" })
.pipe(Effect.flip);
expect(error).toMatchObject({
_tag: "ManagedRelayRequestFailedError",
transportFailed: true,
message: `Could not list relay-managed environments. ${NETWORK_BLOCKING_HINT}`,
});
const encoded = yield* encodeRelayError(error);
const decoded = yield* decodeRelayError(encoded);
expect(decoded.message).toBe(error.message);
}).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
});

it.effect("does not suggest network filtering for an HTTP server error", () => {
const fetchFn = (() =>
Promise.resolve(
new Response("Unavailable", { status: 503 }),
)) satisfies typeof globalThis.fetch;
return Effect.gen(function* () {
const relayClient = yield* ManagedRelay.ManagedRelayClient;
const error = yield* relayClient
.listEnvironments({ clerkToken: "clerk-token" })
.pipe(Effect.flip);
expect(error.message).toBe("Could not list relay-managed environments.");
}).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
});

it.effect("preserves typed relay trace IDs on client errors", () => {
const fetchFn = (() =>
Promise.resolve(
Expand Down
9 changes: 7 additions & 2 deletions packages/client-runtime/src/relay/managedRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
import * as HttpClientError from "effect/unstable/http/HttpClientError";
import type * as HttpMethod from "effect/unstable/http/HttpMethod";
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";

export interface ManagedRelayDpopProofInput {
readonly method: HttpMethod.HttpMethod;
Expand Down Expand Up @@ -126,7 +127,7 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass<Man
},
) {
override get message(): string {
return `${this.activity} timed out.`;
return `${this.activity} timed out. ${NETWORK_BLOCKING_HINT}`;
}
}

Expand All @@ -145,13 +146,15 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass<Mana
"ManagedRelayRequestFailedError",
{
action: ManagedRelayRequestAction,
transportFailed: Schema.optionalKey(Schema.Boolean),
cause: Schema.Defect(),
relayError: Schema.optional(RelayProtectedError),
traceId: Schema.optional(Schema.String),
},
) {
override get message(): string {
return `Could not ${this.action}.`;
const message = `Could not ${this.action}.`;
return this.transportFailed ? `${message} ${NETWORK_BLOCKING_HINT}` : message;
}
}

Expand Down Expand Up @@ -307,6 +310,8 @@ 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 } : {}),
});
Expand Down
Loading
Loading