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
6 changes: 4 additions & 2 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
),
);
Expand All @@ -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)),
}),
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 &&
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/state/thread-outbox.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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({
Expand Down
25 changes: 21 additions & 4 deletions apps/web/src/connection/platform.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConnectionTransientError } from "@t3tools/client-runtime/connection";
import {
AuthStandardClientScopes,
EnvironmentId,
Expand All @@ -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) => {
Expand All @@ -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"),
Expand Down Expand Up @@ -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);
}),
);
});
13 changes: 9 additions & 4 deletions apps/web/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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,
}),
),
);
Expand All @@ -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)),
});
Expand Down Expand Up @@ -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,
}),
});
}),
Expand Down
68 changes: 3 additions & 65 deletions packages/client-runtime/src/connection/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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:",
Expand All @@ -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";
Expand All @@ -114,7 +53,6 @@ describe("connection error mapping", () => {
requestUrlProtocol: "https:",
requestUrlHostname: "environment.example.test",
});
expect(error.cause).toBe(cause);
expect(error).not.toHaveProperty("requestUrl");
});
});
5 changes: 4 additions & 1 deletion packages/client-runtime/src/connection/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
}),
Expand Down
6 changes: 4 additions & 2 deletions packages/client-runtime/src/connection/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
});
},
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 28 additions & 1 deletion packages/client-runtime/src/rpc/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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[] = [];
Expand Down
Loading
Loading