authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 17:54:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:54:08-07:00
log2dca7a1eddd27c823e4d9d0a316864c4ad9f95c5
tree572d533ca6496ff6d5d25060b21f6b992576097a
parent720e126b761ba0da112d8c68b589d20ab98a7eb0
signature Signed by PGP key B5690EEEBB952194

fix(client): explain possible network blocking for T3 Connect (#9783)


12 files changed, 211 insertions(+), 44 deletions(-)

apps/web/src/components/settings/ConnectionsSettings.tsx+3-1
...@@ -1524,7 +1524,9 @@ function SavedBackendListRow({...@@ -1524,7 +1524,9 @@ function SavedBackendListRow({
1524 ) : null}1524 ) : null}
1525 {environment.connection.error && !resumingServerUpdate ? (1525 {environment.connection.error && !resumingServerUpdate ? (
1526 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">1526 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
1527 <span className="truncate">{connectionStatusText(environment.connection)}</span>1527 <span className="min-w-0 break-words">
1528 {connectionStatusText(environment.connection)}
1529 </span>
1528 {errorTraceId ? (1530 {errorTraceId ? (
1529 <button1531 <button
1530 type="button"1532 type="button"
packages/client-runtime/src/authorization/service.ts+4-3
...@@ -80,9 +80,10 @@ function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemp...@@ -80,9 +80,10 @@ function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemp
8080
81const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (81const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (
82 httpBaseUrl: string,82 httpBaseUrl: string,
83 connectionMethod: ClientConnectionMethod,
83) {84) {
84 return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe(85 return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe(
85 Effect.mapError(mapRemoteEnvironmentError),86 Effect.mapError((error) => mapRemoteEnvironmentError(error, connectionMethod)),
86 );87 );
87});88});
8889
...@@ -119,7 +120,7 @@ export const make = Effect.gen(function* () {...@@ -119,7 +120,7 @@ export const make = Effect.gen(function* () {
119 cachedDescriptor.validatedAtEpochMs + BEARER_DESCRIPTOR_CACHE_TTL_MS > now;120 cachedDescriptor.validatedAtEpochMs + BEARER_DESCRIPTOR_CACHE_TTL_MS > now;
120 const descriptor = canReuseDescriptor121 const descriptor = canReuseDescriptor
121 ? cachedDescriptor.descriptor122 ? cachedDescriptor.descriptor
122 : yield* fetchDescriptor(input.httpBaseUrl).pipe(123 : yield* fetchDescriptor(input.httpBaseUrl, input.connectionMethod).pipe(
123 Effect.provideService(HttpClient.HttpClient, httpClient),124 Effect.provideService(HttpClient.HttpClient, httpClient),
124 );125 );
125 if (descriptor.environmentId !== input.expectedEnvironmentId) {126 if (descriptor.environmentId !== input.expectedEnvironmentId) {
...@@ -252,7 +253,7 @@ export const make = Effect.gen(function* () {...@@ -252,7 +253,7 @@ export const make = Effect.gen(function* () {
252 "connection.remote_token_cache": "miss",253 "connection.remote_token_cache": "miss",
253 });254 });
254 const bootstrap = yield* input.obtainBootstrap;255 const bootstrap = yield* input.obtainBootstrap;
255 const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe(256 const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl, "relay").pipe(
256 Effect.provideService(HttpClient.HttpClient, httpClient),257 Effect.provideService(HttpClient.HttpClient, httpClient),
257 Effect.withSpan("environment.authorization.descriptor"),258 Effect.withSpan("environment.authorization.descriptor"),
258 );259 );
packages/client-runtime/src/connection/errors.test.ts+58-2
...@@ -1,12 +1,40 @@...@@ -1,12 +1,40 @@
1import { EnvironmentAuthInvalidError } from "@t3tools/contracts";1import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
2import { RelayAuthInvalidError } from "@t3tools/contracts/relay";2import {
3 RelayAuthInvalidError,
4 RelayEnvironmentEndpointTimedOutError,
5} from "@t3tools/contracts/relay";
3import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
47
5import { mapManagedRelayError, mapRemoteDpopEnvironmentError } from "./errors.ts";8import {
9 mapManagedRelayError,
10 mapRemoteDpopEnvironmentError,
11 mapRemoteEnvironmentError,
12} from "./errors.ts";
6import { DPOP_RETRY_HINT, DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts";13import { DPOP_RETRY_HINT, DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts";
7import { ManagedRelayRequestFailedError } from "../relay/managedRelay.ts";14import { ManagedRelayRequestFailedError } from "../relay/managedRelay.ts";
15import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
16import { RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthTimeoutError } from "../rpc/http.ts";
817
9describe("mapManagedRelayError", () => {18describe("mapManagedRelayError", () => {
19 it("keeps a timeout reported by the relay distinct from a local network failure", () => {
20 const relayError = new RelayEnvironmentEndpointTimedOutError({
21 code: "environment_endpoint_timed_out",
22 traceId: "trace-server-timeout",
23 });
24 const mapped = mapManagedRelayError(
25 new ManagedRelayRequestFailedError({
26 action: "connect relay environment",
27 cause: relayError,
28 relayError,
29 }),
30 );
31 expect(mapped).toMatchObject({
32 reason: "timeout",
33 detail: "Relay timed out while contacting the environment endpoint.",
34 traceId: "trace-server-timeout",
35 });
36 });
37
10 it("presents clock skew as one possible cause for a generic DPoP error", () => {38 it("presents clock skew as one possible cause for a generic DPoP error", () => {
11 const mapped = mapManagedRelayError(39 const mapped = mapManagedRelayError(
12 new ManagedRelayRequestFailedError({40 new ManagedRelayRequestFailedError({
...@@ -48,6 +76,34 @@ describe("mapManagedRelayError", () => {...@@ -48,6 +76,34 @@ describe("mapManagedRelayError", () => {
48});76});
4977
50describe("mapRemoteDpopEnvironmentError", () => {78describe("mapRemoteDpopEnvironmentError", () => {
79 it("keeps relay descriptor auth failures distinct from DPoP proof failures", () => {
80 const error = new EnvironmentAuthInvalidError({
81 code: "auth_invalid",
82 reason: "invalid_credential",
83 traceId: "trace-descriptor",
84 });
85 expect(mapRemoteEnvironmentError(error, "relay").message).toBe(
86 "The environment credential is invalid.",
87 );
88 expect(mapRemoteDpopEnvironmentError(error).message).toBe(
89 `The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`,
90 );
91 });
92
93 it.each([
94 new RemoteEnvironmentAuthFetchError({
95 message: "Failed to fetch remote environment endpoint.",
96 cause: new TypeError("Failed to fetch"),
97 }),
98 new RemoteEnvironmentAuthTimeoutError("https://environment.example.test", 10_000),
99 ])("suggests another network when the relay endpoint cannot be reached: $_tag", (error) => {
100 const mapped = mapRemoteDpopEnvironmentError(error);
101 expect(mapped).toMatchObject({
102 _tag: "ConnectionTransientError",
103 detail: `${error.message} ${NETWORK_BLOCKING_HINT}`,
104 });
105 });
106
51 it("does not present a generic environment auth error as confirmed clock skew", () => {107 it("does not present a generic environment auth error as confirmed clock skew", () => {
52 const mapped = mapRemoteDpopEnvironmentError(108 const mapped = mapRemoteDpopEnvironmentError(
53 new EnvironmentAuthInvalidError({109 new EnvironmentAuthInvalidError({
packages/client-runtime/src/connection/errors.ts+7-4
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { ClientConnectionMethod, EnvironmentId } from "@t3tools/contracts";
2import type { RelayProtectedError } from "@t3tools/contracts/relay";2import type { RelayProtectedError } from "@t3tools/contracts/relay";
3import type { ManagedRelayClientError } from "../relay/managedRelay.ts";3import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
4import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";4import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";
5import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";5import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
6import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
6import {7import {
7 ConnectionBlockedError,8 ConnectionBlockedError,
8 type ConnectionAttemptError,9 type ConnectionAttemptError,
...@@ -113,7 +114,9 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection...@@ -113,7 +114,9 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection
113114
114export function mapRemoteEnvironmentError(115export function mapRemoteEnvironmentError(
115 error: RemoteEnvironmentAuthError,116 error: RemoteEnvironmentAuthError,
117 connectionMethod: ClientConnectionMethod = "direct",
116): ConnectionAttemptError {118): ConnectionAttemptError {
119 const networkHint = connectionMethod === "relay" ? ` ${NETWORK_BLOCKING_HINT}` : "";
117 switch (error._tag) {120 switch (error._tag) {
118 case "EnvironmentAuthInvalidError":121 case "EnvironmentAuthInvalidError":
119 return new ConnectionBlockedError({122 return new ConnectionBlockedError({
...@@ -146,12 +149,12 @@ export function mapRemoteEnvironmentError(...@@ -146,12 +149,12 @@ export function mapRemoteEnvironmentError(
146 case "RemoteEnvironmentAuthTimeoutError":149 case "RemoteEnvironmentAuthTimeoutError":
147 return new ConnectionTransientError({150 return new ConnectionTransientError({
148 reason: "timeout",151 reason: "timeout",
149 detail: error.message,152 detail: `${error.message}${networkHint}`,
150 });153 });
151 case "RemoteEnvironmentAuthFetchError":154 case "RemoteEnvironmentAuthFetchError":
152 return new ConnectionTransientError({155 return new ConnectionTransientError({
153 reason: "network",156 reason: "network",
154 detail: error.message,157 detail: `${error.message}${networkHint}`,
155 });158 });
156 case "EnvironmentInternalError":159 case "EnvironmentInternalError":
157 return new ConnectionTransientError({160 return new ConnectionTransientError({
...@@ -185,5 +188,5 @@ export function mapRemoteDpopEnvironmentError(...@@ -185,5 +188,5 @@ export function mapRemoteDpopEnvironmentError(
185 traceId: error.traceId,188 traceId: error.traceId,
186 });189 });
187 }190 }
188 return mapRemoteEnvironmentError(error);191 return mapRemoteEnvironmentError(error, "relay");
189}192}
packages/client-runtime/src/connection/supervisor.test.ts+31-1
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30import * as RpcSession from "../rpc/session.ts";30import * as RpcSession from "../rpc/session.ts";
31import * as EnvironmentSupervisor from "./supervisor.ts";31import * as EnvironmentSupervisor from "./supervisor.ts";
32import * as ConnectionWakeups from "./wakeups.ts";32import * as ConnectionWakeups from "./wakeups.ts";
33import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
3334
34const TARGET = new PrimaryConnectionTarget({35const TARGET = new PrimaryConnectionTarget({
35 environmentId: EnvironmentId.make("environment-1"),36 environmentId: EnvironmentId.make("environment-1"),
...@@ -467,6 +468,35 @@ describe("EnvironmentSupervisor", () => {...@@ -467,6 +468,35 @@ describe("EnvironmentSupervisor", () => {
467 }).pipe(Effect.provide(TestClock.layer())),468 }).pipe(Effect.provide(TestClock.layer())),
468 );469 );
469470
471 it.effect(
472 "shows a network hint for a stalled relay connection and clears it after recovery",
473 () =>
474 Effect.gen(function* () {
475 const harness = yield* makeHarness({
476 prepare: (attempt) =>
477 attempt === 1 ? Effect.never : Effect.succeed(PREPARED_CONNECTION),
478 });
479 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
480 initiallyDesired: true,
481 }).pipe(Effect.provide(harness.dependencies));
482
483 yield* awaitState(supervisor.state, (state) => state.phase === "connecting");
484 yield* TestClock.adjust("15 seconds");
485 const failed = yield* awaitState(supervisor.state, (state) => state.phase === "backoff");
486 expect(failed.lastFailure?.message).toBe(
487 `Test environment did not respond during connection setup. ${NETWORK_BLOCKING_HINT}`,
488 );
489
490 yield* TestClock.adjust("3 seconds");
491 const recovered = yield* awaitState(
492 supervisor.state,
493 (state) => state.phase === "connected",
494 );
495 expect(recovered.lastFailure).toBeNull();
496 expect(yield* Ref.get(harness.prepareCount)).toBe(2);
497 }).pipe(Effect.provide(TestClock.layer())),
498 );
499
470 it.effect("converts unexpected driver defects into retryable failures", () =>500 it.effect("converts unexpected driver defects into retryable failures", () =>
471 Effect.gen(function* () {501 Effect.gen(function* () {
472 const harness = yield* makeHarness({502 const harness = yield* makeHarness({
...@@ -475,7 +505,7 @@ describe("EnvironmentSupervisor", () => {...@@ -475,7 +505,7 @@ describe("EnvironmentSupervisor", () => {
475 ? Effect.die(new Error("Native transport defect."))505 ? Effect.die(new Error("Native transport defect."))
476 : Effect.succeed(PREPARED_CONNECTION),506 : Effect.succeed(PREPARED_CONNECTION),
477 });507 });
478 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {508 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
479 initiallyDesired: true,509 initiallyDesired: true,
480 }).pipe(Effect.provide(harness.dependencies));510 }).pipe(Effect.provide(harness.dependencies));
481511
packages/client-runtime/src/connection/supervisor.ts+6-2
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28} from "./model.ts";28} from "./model.ts";
29import * as RpcSession from "../rpc/session.ts";29import * as RpcSession from "../rpc/session.ts";
30import { safeErrorLogAttributes } from "../errors/safeLog.ts";30import { safeErrorLogAttributes } from "../errors/safeLog.ts";
31import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
31import * as ConnectionWakeups from "./wakeups.ts";32import * as ConnectionWakeups from "./wakeups.ts";
3233
33const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const;34const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const;
...@@ -241,6 +242,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -241,6 +242,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
241 | ConnectionWakeups.ConnectionWakeups242 | ConnectionWakeups.ConnectionWakeups
242> {243> {
243 const target = entry.target;244 const target = entry.target;
245 const setupTimeoutDetail = `${target.label} did not respond during connection setup.${
246 target._tag === "RelayConnectionTarget" ? ` ${NETWORK_BLOCKING_HINT}` : ""
247 }`;
244 yield* annotateTarget(target);248 yield* annotateTarget(target);
245249
246 const connectivity = yield* Connectivity.Connectivity;250 const connectivity = yield* Connectivity.Connectivity;
...@@ -626,7 +630,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -626,7 +630,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
626 } else {630 } else {
627 replacementError = new ConnectionTransientError({631 replacementError = new ConnectionTransientError({
628 reason: "timeout",632 reason: "timeout",
629 detail: `${target.label} did not respond during connection setup.`,633 detail: setupTimeoutDetail,
630 });634 });
631 }635 }
632636
...@@ -708,7 +712,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -708,7 +712,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
708 failure: {712 failure: {
709 error: new ConnectionTransientError({713 error: new ConnectionTransientError({
710 reason: "timeout",714 reason: "timeout",
711 detail: `${target.label} did not respond during connection setup.`,715 detail: setupTimeoutDetail,
712 }),716 }),
713 attemptSpan: Option.none(),717 attemptSpan: Option.none(),
714 },718 },
packages/client-runtime/src/errors/network.ts created+4
...@@ -0,0 +1,4 @@
1// A failed request cannot distinguish filtering from an outage. Keep this a
2// possible cause, and suggest a way to check without changing server settings.
3export const NETWORK_BLOCKING_HINT =
4 "Your DNS or firewall may be blocking T3 Connect. Try another network, such as a phone hotspot.";
packages/client-runtime/src/relay/discovery.test.ts+2-1
...@@ -19,6 +19,7 @@ import * as Connectivity from "../connection/connectivity.ts";...@@ -19,6 +19,7 @@ import * as Connectivity from "../connection/connectivity.ts";
19import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts";19import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts";
20import * as ConnectionWakeups from "../connection/wakeups.ts";20import * as ConnectionWakeups from "../connection/wakeups.ts";
21import * as RelayEnvironmentDiscovery from "./discovery.ts";21import * as RelayEnvironmentDiscovery from "./discovery.ts";
22import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
2223
23const environments = [24const environments = [
24 {25 {
...@@ -305,7 +306,7 @@ describe("RelayEnvironmentDiscovery", () => {...@@ -305,7 +306,7 @@ describe("RelayEnvironmentDiscovery", () => {
305 expect(Option.getOrThrow(state.error)).toMatchObject({306 expect(Option.getOrThrow(state.error)).toMatchObject({
306 _tag: "ConnectionTransientError",307 _tag: "ConnectionTransientError",
307 reason: "timeout",308 reason: "timeout",
308 message: "Relay environment listing timed out.",309 message: `Relay environment listing timed out. ${NETWORK_BLOCKING_HINT}`,
309 });310 });
310 }).pipe(Effect.provide(layer));311 }).pipe(Effect.provide(layer));
311 }),312 }),
packages/client-runtime/src/relay/managedRelay.test.ts+39-1
...@@ -9,10 +9,15 @@ import * as Effect from "effect/Effect";...@@ -9,10 +9,15 @@ import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Tracer from "effect/Tracer";11import * as Tracer from "effect/Tracer";
12import * as Schema from "effect/Schema";
12import * as TestClock from "effect/testing/TestClock";13import * as TestClock from "effect/testing/TestClock";
1314
14import * as ManagedRelay from "./managedRelay.ts";15import * as ManagedRelay from "./managedRelay.ts";
15import { remoteHttpClientLayer } from "../rpc/http.ts";16import { remoteHttpClientLayer } from "../rpc/http.ts";
17import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
18
19const encodeRelayError = Schema.encodeEffect(ManagedRelay.ManagedRelayClientError);
20const decodeRelayError = Schema.decodeUnknownEffect(ManagedRelay.ManagedRelayClientError);
1621
17function managedRelayTestLayer(22function managedRelayTestLayer(
18 fetchFn: typeof globalThis.fetch,23 fetchFn: typeof globalThis.fetch,
...@@ -529,11 +534,44 @@ describe("ManagedRelayClient", () => {...@@ -529,11 +534,44 @@ describe("ManagedRelayClient", () => {
529 _tag: "ManagedRelayRequestTimeoutError",534 _tag: "ManagedRelayRequestTimeoutError",
530 activity: "Relay environment listing",535 activity: "Relay environment listing",
531 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,536 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
532 message: "Relay environment listing timed out.",537 message: `Relay environment listing timed out. ${NETWORK_BLOCKING_HINT}`,
533 });538 });
534 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn))));539 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn))));
535 });540 });
536541
542 it.effect("suggests checking network filtering when fetch fails without a response", () => {
543 const fetchFn = (() =>
544 Promise.reject(new TypeError("Failed to fetch"))) satisfies typeof globalThis.fetch;
545 return Effect.gen(function* () {
546 const relayClient = yield* ManagedRelay.ManagedRelayClient;
547 const error = yield* relayClient
548 .listEnvironments({ clerkToken: "clerk-token" })
549 .pipe(Effect.flip);
550 expect(error).toMatchObject({
551 _tag: "ManagedRelayRequestFailedError",
552 transportFailed: true,
553 message: `Could not list relay-managed environments. ${NETWORK_BLOCKING_HINT}`,
554 });
555 const encoded = yield* encodeRelayError(error);
556 const decoded = yield* decodeRelayError(encoded);
557 expect(decoded.message).toBe(error.message);
558 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
559 });
560
561 it.effect("does not suggest network filtering for an HTTP server error", () => {
562 const fetchFn = (() =>
563 Promise.resolve(
564 new Response("Unavailable", { status: 503 }),
565 )) satisfies typeof globalThis.fetch;
566 return Effect.gen(function* () {
567 const relayClient = yield* ManagedRelay.ManagedRelayClient;
568 const error = yield* relayClient
569 .listEnvironments({ clerkToken: "clerk-token" })
570 .pipe(Effect.flip);
571 expect(error.message).toBe("Could not list relay-managed environments.");
572 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
573 });
574
537 it.effect("preserves typed relay trace IDs on client errors", () => {575 it.effect("preserves typed relay trace IDs on client errors", () => {
538 const fetchFn = (() =>576 const fetchFn = (() =>
539 Promise.resolve(577 Promise.resolve(
packages/client-runtime/src/relay/managedRelay.ts+7-2
...@@ -44,6 +44,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";...@@ -44,6 +44,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
44import * as HttpClientError from "effect/unstable/http/HttpClientError";44import * as HttpClientError from "effect/unstable/http/HttpClientError";
45import type * as HttpMethod from "effect/unstable/http/HttpMethod";45import type * as HttpMethod from "effect/unstable/http/HttpMethod";
46import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";46import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
47import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
4748
48export interface ManagedRelayDpopProofInput {49export interface ManagedRelayDpopProofInput {
49 readonly method: HttpMethod.HttpMethod;50 readonly method: HttpMethod.HttpMethod;
...@@ -126,7 +127,7 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass<Man...@@ -126,7 +127,7 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass<Man
126 },127 },
127) {128) {
128 override get message(): string {129 override get message(): string {
129 return `${this.activity} timed out.`;130 return `${this.activity} timed out. ${NETWORK_BLOCKING_HINT}`;
130 }131 }
131}132}
132133
...@@ -145,13 +146,15 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass<Mana...@@ -145,13 +146,15 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass<Mana
145 "ManagedRelayRequestFailedError",146 "ManagedRelayRequestFailedError",
146 {147 {
147 action: ManagedRelayRequestAction,148 action: ManagedRelayRequestAction,
149 transportFailed: Schema.optionalKey(Schema.Boolean),
148 cause: Schema.Defect(),150 cause: Schema.Defect(),
149 relayError: Schema.optional(RelayProtectedError),151 relayError: Schema.optional(RelayProtectedError),
150 traceId: Schema.optional(Schema.String),152 traceId: Schema.optional(Schema.String),
151 },153 },
152) {154) {
153 override get message(): string {155 override get message(): string {
154 return `Could not ${this.action}.`;156 const message = `Could not ${this.action}.`;
157 return this.transportFailed ? `${message} ${NETWORK_BLOCKING_HINT}` : message;
155 }158 }
156}159}
157160
...@@ -307,6 +310,8 @@ function relayRequestError(action: ManagedRelayRequestAction) {...@@ -307,6 +310,8 @@ function relayRequestError(action: ManagedRelayRequestAction) {
307 return (cause: RelayHttpRequestError): ManagedRelayClientError =>310 return (cause: RelayHttpRequestError): ManagedRelayClientError =>
308 new ManagedRelayRequestFailedError({311 new ManagedRelayRequestFailedError({
309 action,312 action,
313 transportFailed:
314 HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError",
310 cause,315 cause,
311 ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),316 ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),
312 });317 });
packages/client-runtime/src/rpc/session.test.ts+32-20
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30 ConnectionBlockedError,30 ConnectionBlockedError,
31 ConnectionTransientError,31 ConnectionTransientError,
32 PrimaryConnectionTarget,32 PrimaryConnectionTarget,
33 RelayConnectionTarget,
33 type PreparedConnection,34 type PreparedConnection,
34} from "../connection/model.ts";35} from "../connection/model.ts";
35import * as EnvironmentSupervisor from "../connection/supervisor.ts";36import * as EnvironmentSupervisor from "../connection/supervisor.ts";
...@@ -37,6 +38,7 @@ import * as Persistence from "../platform/persistence.ts";...@@ -37,6 +38,7 @@ import * as Persistence from "../platform/persistence.ts";
37import * as RpcSession from "./session.ts";38import * as RpcSession from "./session.ts";
38import { makeEnvironmentServerConfigState } from "../state/server.ts";39import { makeEnvironmentServerConfigState } from "../state/server.ts";
39import { applyServerConfigProjection } from "../state/serverConfigProjection.ts";40import { applyServerConfigProjection } from "../state/serverConfigProjection.ts";
41import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
4042
41type SocketEventType = "open" | "message" | "close" | "error";43type SocketEventType = "open" | "message" | "close" | "error";
42type SocketEvent = {44type SocketEvent = {
...@@ -1157,27 +1159,37 @@ describe("RpcSessionFactory", () => {...@@ -1157,27 +1159,37 @@ describe("RpcSessionFactory", () => {
1157 ),1159 ),
1158 );1160 );
11591161
1160 it.effect("fails readiness when the websocket never opens", () =>1162 for (const relay of [false, true]) {
1161 Effect.gen(function* () {1163 it.effect(`fails readiness when the ${relay ? "relay" : "direct"} websocket never opens`, () =>
1162 const { factory, sockets } = yield* makeFactory();1164 Effect.gen(function* () {
1165 const { factory, sockets } = yield* makeFactory();
11631166
1164 const error = yield* Effect.scoped(1167 const error = yield* Effect.scoped(
1165 Effect.gen(function* () {1168 Effect.gen(function* () {
1166 const session = yield* factory.connect(PREPARED);1169 const session = yield* factory.connect({
1167 const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));1170 ...PREPARED,
1168 yield* awaitSocket(sockets);1171 target: relay
1172 ? new RelayConnectionTarget({
1173 environmentId: TARGET.environmentId,
1174 label: TARGET.label,
1175 })
1176 : TARGET,
1177 });
1178 const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));
1179 yield* awaitSocket(sockets);
11691180
1170 yield* TestClock.adjust("15 seconds");1181 yield* TestClock.adjust("15 seconds");
1171 return yield* Fiber.join(readyFiber);1182 return yield* Fiber.join(readyFiber);
1172 }),1183 }),
1173 );1184 );
11741185
1175 expect(error).toBeInstanceOf(ConnectionTransientError);1186 expect(error).toBeInstanceOf(ConnectionTransientError);
1176 expect(error).toMatchObject({1187 expect(error).toMatchObject({
1177 reason: "transport",1188 reason: "transport",
1178 message: "Test environment could not establish a WebSocket connection.",1189 message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`,
1179 });1190 });
1180 expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED);1191 expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED);
1181 }).pipe(Effect.provide(TestClock.layer())),1192 }).pipe(Effect.provide(TestClock.layer())),
1182 );1193 );
1194 }
1183});1195});
packages/client-runtime/src/rpc/session.ts+18-7
...@@ -15,6 +15,7 @@ import * as Option from "effect/Option";...@@ -15,6 +15,7 @@ import * as Option from "effect/Option";
15import * as PubSub from "effect/PubSub";15import * as PubSub from "effect/PubSub";
16import * as Ref from "effect/Ref";16import * as Ref from "effect/Ref";
17import * as Schedule from "effect/Schedule";17import * as Schedule from "effect/Schedule";
18import * as Schema from "effect/Schema";
18import type * as Scope from "effect/Scope";19import type * as Scope from "effect/Scope";
19import * as Stream from "effect/Stream";20import * as Stream from "effect/Stream";
20import type * as Rpc from "effect/unstable/rpc/Rpc";21import type * as Rpc from "effect/unstable/rpc/Rpc";
...@@ -24,6 +25,7 @@ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";...@@ -24,6 +25,7 @@ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
24import * as Socket from "effect/unstable/socket/Socket";25import * as Socket from "effect/unstable/socket/Socket";
2526
26import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts";27import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts";
28import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
27import type {29import type {
28 ConnectionAttemptError,30 ConnectionAttemptError,
29 ConnectionTransientError,31 ConnectionTransientError,
...@@ -117,8 +119,11 @@ function serverConfigReplayEvents(...@@ -117,8 +119,11 @@ function serverConfigReplayEvents(
117 ];119 ];
118}120}
119121
122const isSocketErrorReason = Schema.is(Socket.SocketErrorReason);
123
120function mapSessionRpcError(124function mapSessionRpcError(
121 error: InitialConfigError | ProbeError | ServerConfigSubscriptionError,125 error: InitialConfigError | ProbeError | ServerConfigSubscriptionError,
126 networkHint: string,
122): ConnectionAttemptError {127): ConnectionAttemptError {
123 switch (error._tag) {128 switch (error._tag) {
124 case "EnvironmentAuthorizationError":129 case "EnvironmentAuthorizationError":
...@@ -135,7 +140,7 @@ function mapSessionRpcError(...@@ -135,7 +140,7 @@ function mapSessionRpcError(
135 case "RpcClientError":140 case "RpcClientError":
136 return new ConnectionTransientErrorClass({141 return new ConnectionTransientErrorClass({
137 reason: "transport",142 reason: "transport",
138 detail: error.message,143 detail: `${error.message}${isSocketErrorReason(error.reason) ? networkHint : ""}`,
139 });144 });
140 }145 }
141}146}
...@@ -150,6 +155,10 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (...@@ -150,6 +155,10 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
150 };155 };
151156
152 const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {157 const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {
158 const networkHint =
159 connection.target._tag === "RelayConnectionTarget" ? ` ${NETWORK_BLOCKING_HINT}` : "";
160 const mapRpcError = (error: Parameters<typeof mapSessionRpcError>[0]) =>
161 mapSessionRpcError(error, networkHint);
153 yield* Effect.annotateCurrentSpan({162 yield* Effect.annotateCurrentSpan({
154 "connection.environment.id": connection.environmentId,163 "connection.environment.id": connection.environmentId,
155 });164 });
...@@ -164,9 +173,11 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (...@@ -164,9 +173,11 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
164 disconnected,173 disconnected,
165 new ConnectionTransientErrorClass({174 new ConnectionTransientErrorClass({
166 reason: "transport",175 reason: "transport",
167 detail: wasConnected176 detail: `${
168 ? `${connection.label} disconnected.`177 wasConnected
169 : `${connection.label} could not establish a WebSocket connection.`,178 ? `${connection.label} disconnected.`
179 : `${connection.label} could not establish a WebSocket connection.`
180 }${networkHint}`,
170 }),181 }),
171 ),182 ),
172 ),183 ),
...@@ -263,7 +274,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (...@@ -263,7 +274,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
263 }274 }
264 return Effect.all([275 return Effect.all([
265 Deferred.failCause(serverConfigExit, exit.cause),276 Deferred.failCause(serverConfigExit, exit.cause),
266 Deferred.failCause(configSubscriptionClosed, Cause.map(exit.cause, mapSessionRpcError)),277 Deferred.failCause(configSubscriptionClosed, Cause.map(exit.cause, mapRpcError)),
267 ]).pipe(Effect.asVoid);278 ]).pipe(Effect.asVoid);
268 }),279 }),
269 );280 );
...@@ -271,7 +282,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (...@@ -271,7 +282,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
271 const initialConfig = Effect.raceFirst(282 const initialConfig = Effect.raceFirst(
272 Deferred.await(initialConfigDeferred),283 Deferred.await(initialConfigDeferred),
273 Deferred.await(serverConfigExit).pipe(284 Deferred.await(serverConfigExit).pipe(
274 Effect.mapError(mapSessionRpcError),285 Effect.mapError(mapRpcError),
275 Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)),286 Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)),
276 ),287 ),
277 ).pipe(Effect.withSpan("environment.initialSync"));288 ).pipe(Effect.withSpan("environment.initialSync"));
...@@ -330,7 +341,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (...@@ -330,7 +341,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
330 (config.environment.capabilities.connectionProbe === true341 (config.environment.capabilities.connectionProbe === true
331 ? protocolClient[WS_METHODS.serverProbe]({})342 ? protocolClient[WS_METHODS.serverProbe]({})
332 : protocolClient[WS_METHODS.serverGetConfig]({})343 : protocolClient[WS_METHODS.serverGetConfig]({})
333 ).pipe(Effect.mapError(mapSessionRpcError)),344 ).pipe(Effect.mapError(mapRpcError)),
334 ),345 ),
335 Effect.asVoid,346 Effect.asVoid,
336 Effect.withSpan("clientRuntime.connection.rpcSession.probe"),347 Effect.withSpan("clientRuntime.connection.rpcSession.probe"),