From f255391d4dd1c781fda8ac7acd796a918c99a6ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 02:57:19 -0700 Subject: [PATCH 1/4] structure client RPC unavailable errors Co-authored-by: codex --- .../client-runtime/src/rpc/client.test.ts | 29 ++++++++++++++++++- packages/client-runtime/src/rpc/client.ts | 23 ++++++++++----- 2 files changed, 43 insertions(+), 9 deletions(-) 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..4295dd2772e 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,16 @@ 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 interface EnvironmentRpcRequestObservation { readonly environmentId: string; @@ -85,7 +91,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 +100,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 +118,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 +139,7 @@ export function runStream( EnvironmentSupervisor > { return Stream.unwrap( - currentSession().pipe( + currentSession(tag).pipe( Effect.map((session) => { const method = session.client[tag] as ( input: EnvironmentRpcInput, From ee6201fbb80c629db45fc76a3bf6a6124ffb54be Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:37:15 -0700 Subject: [PATCH 2/4] Preserve typed RPC retry classification Co-authored-by: codex --- apps/mobile/src/state/thread-outbox-model.ts | 4 ++++ apps/mobile/src/state/thread-outbox.test.ts | 10 ++++++++++ packages/client-runtime/src/rpc/client.ts | 2 ++ 3 files changed, 16 insertions(+) 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/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4295dd2772e..c2ab4b5b91c 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -26,6 +26,8 @@ export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 10:58:40 -0700 Subject: [PATCH 3/4] fix(client-runtime): bound RPC transport diagnostics Co-authored-by: codex --- packages/client-runtime/src/rpc/client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c2ab4b5b91c..1e136768c19 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -204,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, }, From 0c5fe3cad7db3e7936570f45c947386f970ba251 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:17:10 -0700 Subject: [PATCH 4/4] test(client-runtime): focus error mapping coverage Co-authored-by: codex --- .../src/connection/errors.test.ts | 68 +------------------ 1 file changed, 3 insertions(+), 65 deletions(-) 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"); }); });