diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 769632a8fcb..0e33f2f0ec7 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -100,7 +100,8 @@ const capabilitiesLayer = Layer.succeedContext( (error) => new ConnectionTransientError({ reason: "network", - detail: error.message, + detail: "Could not read the T3 Cloud session token.", + cause: error, }), ), ); @@ -126,7 +127,8 @@ const capabilitiesLayer = Layer.succeedContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not load the mobile device identity: ${String(cause)}`, + detail: "Could not load the mobile device identity.", + cause, }), }).pipe(Effect.map(Option.some)), }), diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index aa7a1055136..a35f29a2994 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,4 +1,5 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { isEnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -109,6 +110,9 @@ function errorMessage(error: unknown): string | null { } export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { + if (isEnvironmentRpcUnavailableError(error)) { + return true; + } if ( typeof error === "object" && error !== null && diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d2634fb966f..9a1ea4dca5f 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -215,6 +216,15 @@ describe("thread outbox", () => { }); it("retries transport failures but drops deterministic command failures", () => { + expect( + shouldRetryThreadOutboxDelivery( + new EnvironmentRpcUnavailableError({ + environmentId: EnvironmentId.make("environment-1"), + environmentLabel: "Test environment", + method: "thread.turn.start", + }), + ), + ).toBe(true); expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true); expect( shouldRetryThreadOutboxDelivery({ diff --git a/apps/web/src/connection/platform.test.ts b/apps/web/src/connection/platform.test.ts index 2b428e26698..3027105b31c 100644 --- a/apps/web/src/connection/platform.test.ts +++ b/apps/web/src/connection/platform.test.ts @@ -1,3 +1,4 @@ +import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; import { AuthStandardClientScopes, EnvironmentId, @@ -18,7 +19,7 @@ const TARGET: DesktopSshEnvironmentTarget = { function makeBridge( calls: string[], - options?: { readonly failDescriptor?: boolean }, + options?: { readonly descriptorError?: Error }, ): DesktopBridge { return { ensureSshEnvironment: async (target: DesktopSshEnvironmentTarget) => { @@ -32,8 +33,8 @@ function makeBridge( }, fetchSshEnvironmentDescriptor: async () => { calls.push("descriptor"); - if (options?.failDescriptor === true) { - throw new Error("descriptor unavailable"); + if (options?.descriptorError !== undefined) { + throw options.descriptorError; } return { environmentId: EnvironmentId.make("environment-ssh"), @@ -78,11 +79,27 @@ describe("desktop SSH pairing", () => { const calls: string[] = []; yield* provisionDesktopSshEnvironment( - makeBridge(calls, { failDescriptor: true }), + makeBridge(calls, { descriptorError: new Error("descriptor unavailable") }), TARGET, ).pipe(Effect.flip); expect(calls).toEqual(["ensure", "descriptor"]); }), ); + + it.effect("preserves SSH preparation causes without exposing their message", () => + Effect.gen(function* () { + const cause = new Error("descriptor response contained a private endpoint"); + + const error = yield* provisionDesktopSshEnvironment( + makeBridge([], { descriptorError: cause }), + TARGET, + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error.message).toBe("Could not prepare the SSH environment."); + expect(error.message).not.toContain(cause.message); + expect(error.cause).toBe(cause); + }), + ); }); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 1b7cba5cbfd..45346f07e72 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -111,11 +111,13 @@ function sshPreparationError(cause: unknown) { return new ConnectionBlockedError({ reason: "authentication", detail: message, + cause, }); } return new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not prepare the SSH environment: ${message}`, + detail: "Could not prepare the SSH environment.", + cause, }); } @@ -172,7 +174,8 @@ const capabilitiesLayer = Layer.effectContext( (error) => new ConnectionTransientError({ reason: "network", - detail: error.message, + detail: "Could not read the T3 Cloud session token.", + cause: error, }), ), ); @@ -194,7 +197,8 @@ const capabilitiesLayer = Layer.effectContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not load the desktop primary credential: ${String(cause)}`, + detail: "Could not load the desktop primary credential.", + cause, }), }).pipe(Effect.map(Option.fromNullishOr)), }); @@ -250,7 +254,8 @@ const capabilitiesLayer = Layer.effectContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not disconnect the SSH environment: ${String(cause)}`, + detail: "Could not disconnect the SSH environment.", + cause, }), }); }), diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts index c078408c48a..55b31edbc19 100644 --- a/packages/client-runtime/src/connection/errors.test.ts +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -1,72 +1,13 @@ -import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; -import { RelayAuthInvalidError } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; -import { mapManagedRelayError, mapRemoteEnvironmentError } from "./errors.ts"; -import * as ManagedRelay from "../relay/managedRelay.ts"; +import { mapRemoteEnvironmentError } from "./errors.ts"; import { RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthUndeclaredStatusError, } from "../rpc/http.ts"; describe("connection error mapping", () => { - it("retains the managed relay request as the cause when classifying a protected error", () => { - const relayError = new RelayAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_bearer", - traceId: "relay-trace-id", - }); - const source = new ManagedRelay.ManagedRelayRequestFailedError({ - action: "connect relay environment", - cause: relayError, - relayError, - traceId: relayError.traceId, - }); - - const error = mapManagedRelayError(source); - - expect(error).toMatchObject({ - _tag: "ConnectionBlockedError", - reason: "authentication", - traceId: "relay-trace-id", - }); - expect(error.cause).toBe(source); - }); - - it("retains a managed relay timeout and its structured activity", () => { - const source = new ManagedRelay.ManagedRelayRequestTimeoutError({ - activity: "Relay environment connection", - timeoutMs: 10_000, - }); - - const error = mapManagedRelayError(source); - - expect(error).toMatchObject({ - _tag: "ConnectionTransientError", - reason: "timeout", - }); - expect(error.cause).toBe(source); - expect(source.activity).toBe("Relay environment connection"); - }); - - it("retains structured remote authorization failures", () => { - const source = new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "environment-trace-id", - }); - - const error = mapRemoteEnvironmentError(source); - - expect(error).toMatchObject({ - _tag: "ConnectionBlockedError", - reason: "authentication", - traceId: "environment-trace-id", - }); - expect(error.cause).toBe(source); - }); - - it("retains local transport failures without deriving their message from the cause", () => { + it("keeps transport diagnostics structured and redacted", () => { const transportCause = new Error("sensitive transport implementation detail"); const requestUrl = "https://environment-user:environment-password@environment.example.test/private/session?access_token=environment-secret#environment-fragment"; @@ -82,8 +23,6 @@ describe("connection error mapping", () => { _tag: "ConnectionTransientError", reason: "network", }); - expect(error.cause).toBe(source); - expect(source.cause).toBe(transportCause); expect(source).toMatchObject({ requestUrlInputLength: requestUrl.length, requestUrlProtocol: "https:", @@ -102,7 +41,7 @@ describe("connection error mapping", () => { } }); - it("retains the HTTP client cause for undeclared statuses", () => { + it("keeps undeclared status diagnostics structured and redacted", () => { const cause = new Error("upstream response metadata"); const requestUrl = "https://environment-user:environment-password@environment.example.test/private/session?access_token=environment-secret#environment-fragment"; @@ -114,7 +53,6 @@ describe("connection error mapping", () => { requestUrlProtocol: "https:", requestUrlHostname: "environment.example.test", }); - expect(error.cause).toBe(cause); expect(error).not.toHaveProperty("requestUrl"); }); }); diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 9bee0dad6fb..2bdd57dbea0 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -155,7 +155,10 @@ describe("connection onboarding", () => { expect(error).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration", - message: "Enter a backend URL.", + message: "The pairing details are invalid.", + cause: expect.objectContaining({ + message: "Enter a backend URL.", + }), }); expect(calls).toEqual([]); }), diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2c..39db24a8a1b 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -77,7 +77,8 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso catch: (cause) => new ConnectionBlockedError({ reason: "configuration", - detail: cause instanceof Error ? cause.message : "The pairing details are invalid.", + detail: "The pairing details are invalid.", + cause, }), }); }, @@ -189,7 +190,8 @@ export const prepareBearerConnectionUpdate = Effect.fn( catch: (cause) => new ConnectionBlockedError({ reason: "configuration", - detail: cause instanceof Error ? cause.message : "The environment URL is invalid.", + detail: "The environment URL is invalid.", + cause, }), }); const connectionId = entry.target.connectionId; diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 507d137cacc..64d06184dc3 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -25,7 +25,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; +import { + EnvironmentRpcRequestObserver, + EnvironmentRpcUnavailableError, + request, + runStream, + subscribe, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -77,6 +83,27 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("reports the disconnected environment and requested method", () => + Effect.gen(function* () { + const { supervisor } = yield* makeHarness(); + + const error = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( + Effect.flip, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(error).toBeInstanceOf(EnvironmentRpcUnavailableError); + expect(error).toMatchObject({ + environmentId: TARGET.environmentId, + environmentLabel: TARGET.label, + method: WS_METHODS.cloudGetRelayClientStatus, + }); + expect(error.message).toBe( + `Test environment is not connected for RPC method ${WS_METHODS.cloudGetRelayClientStatus}.`, + ); + }), + ); + it.effect("observes unary requests until they complete", () => Effect.gen(function* () { const observations: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 92892431e45..1e136768c19 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,4 +1,4 @@ -import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; +import { EnvironmentId, ORCHESTRATION_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import type * as Duration from "effect/Duration"; @@ -15,10 +15,18 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass()( "EnvironmentRpcUnavailableError", { - environmentId: Schema.String, - message: Schema.String, + environmentId: EnvironmentId, + environmentLabel: Schema.String, + method: Schema.optionalKey(Schema.String), }, -) {} +) { + override get message(): string { + const method = this.method === undefined ? "" : ` for RPC method ${this.method}`; + return `${this.environmentLabel} is not connected${method}.`; + } +} + +export const isEnvironmentRpcUnavailableError = Schema.is(EnvironmentRpcUnavailableError); export interface EnvironmentRpcRequestObservation { readonly environmentId: string; @@ -85,7 +93,7 @@ export type EnvironmentRpcStreamFailure = ? E : never; -const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { +const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* (method?: string) { const supervisor = yield* EnvironmentSupervisor; return yield* SubscriptionRef.get(supervisor.session).pipe( Effect.flatMap( @@ -94,7 +102,8 @@ const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { Effect.fail( new EnvironmentRpcUnavailableError({ environmentId: supervisor.target.environmentId, - message: `${supervisor.target.label} is not connected.`, + environmentLabel: supervisor.target.label, + ...(method === undefined ? {} : { method }), }), ), onSome: Effect.succeed, @@ -111,7 +120,7 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* < "environment.id": supervisor.target.environmentId, "rpc.method": tag, }); - const session = yield* currentSession(); + const session = yield* currentSession(tag); const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( input: EnvironmentRpcInput, @@ -132,7 +141,7 @@ export function runStream( EnvironmentSupervisor > { return Stream.unwrap( - currentSession().pipe( + currentSession(tag).pipe( Effect.map((session) => { const method = session.client[tag] as ( input: EnvironmentRpcInput, @@ -195,7 +204,8 @@ export function subscribe( Effect.logWarning( "Durable RPC subscription lost its transport; waiting for the next session.", { - cause: Cause.pretty(cause), + errorTag: "RpcClientError", + reasonCount: cause.reasons.length, method: tag, environmentId: supervisor.target.environmentId, },