Stabilize TS workbench QA and RPC timeouts

This commit is contained in:
elpresidank 2026-06-01 17:23:34 -05:00
parent 3515106670
commit 952daf325d
9 changed files with 183 additions and 156 deletions

View file

@ -0,0 +1,79 @@
import { Effect, Stream } from "effect";
import { describe, expect, it, vi } from "vitest";
import { DispatchError, DispatchStreamChunk } from "../rpc/contract";
import { EffectRpcClient, type DispatchInput } from "../socket/effect-rpc-client";
import { BaseApi } from "../socket/trustgraph-socket";
const input: DispatchInput = {
scope: "global",
service: "config",
request: { operation: "list" },
};
describe("Effect RPC request policy", () => {
it("threads BaseApi timeout and retry options into dispatch calls", async () => {
const dispatch = vi.fn(() => Promise.resolve({ ok: true }));
const api = Object.create(BaseApi.prototype) as BaseApi;
(api as unknown as { rpc: { dispatch: typeof dispatch } }).rpc = {
dispatch,
};
await api.makeRequest("config", { operation: "list" }, 25, 2);
expect(dispatch).toHaveBeenCalledWith(input, {
timeoutMs: 25,
retries: 2,
});
});
it("rejects stalled dispatch calls at the requested timeout", async () => {
const client = Object.create(EffectRpcClient.prototype) as EffectRpcClient;
const startedAt = Date.now();
setClientPromise(client, {
Dispatch: () => Effect.never,
DispatchStream: () => Stream.never,
});
await expect(
client.dispatch(input, { timeoutMs: 20, retries: 1 }),
).rejects.toBeInstanceOf(DispatchError);
expect(Date.now() - startedAt).toBeLessThan(1_000);
});
it("retries dispatch failures up to the requested attempt count", async () => {
const client = Object.create(EffectRpcClient.prototype) as EffectRpcClient;
let attempts = 0;
setClientPromise(client, {
Dispatch: () =>
Effect.suspend(() => {
attempts += 1;
if (attempts < 3) {
return Effect.fail(new DispatchError({ message: String(attempts) }));
}
return Effect.succeed({ ok: true });
}),
DispatchStream: () => Stream.never,
});
await expect(client.dispatch(input, { timeoutMs: 100, retries: 3 })).resolves.toEqual({
ok: true,
});
expect(attempts).toBe(3);
});
});
function setClientPromise(
client: EffectRpcClient,
fakeClient: {
Dispatch: (payload: unknown) => Effect.Effect<unknown, DispatchError>;
DispatchStream: (payload: unknown) => Stream.Stream<DispatchStreamChunk, DispatchError>;
},
): void {
(client as unknown as { clientPromise: Promise<typeof fakeClient> }).clientPromise =
Promise.resolve(fakeClient);
}

View file

@ -30,6 +30,14 @@ export interface DispatchInput {
request: Record<string, unknown>;
}
export interface DispatchOptions {
readonly timeoutMs?: number;
readonly retries?: number;
}
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
const DEFAULT_REQUEST_ATTEMPTS = 3;
export class EffectRpcClient {
private readonly url: string;
private readonly onConnect: (() => void) | undefined;
@ -68,30 +76,36 @@ export class EffectRpcClient {
};
}
async dispatch(input: DispatchInput): Promise<unknown> {
async dispatch(input: DispatchInput, options: DispatchOptions = {}): Promise<unknown> {
const client = await this.clientPromise;
return await Effect.runPromise(client.Dispatch(new DispatchPayload(input)));
return await Effect.runPromise(
this.withRequestPolicy(client.Dispatch(new DispatchPayload(input)), options),
);
}
async dispatchStream(
input: DispatchInput,
receiver: (chunk: DispatchStreamChunk) => boolean,
options: DispatchOptions = {},
): Promise<DispatchStreamChunk | undefined> {
const client = await this.clientPromise;
let last: DispatchStreamChunk | undefined;
await Effect.runPromise(
client.DispatchStream(new DispatchPayload(input)).pipe(
Stream.runForEach((chunk) =>
Effect.suspend(() => {
last = chunk;
if (receiver(chunk)) return Effect.fail(new StopStreaming());
return Effect.void;
}),
),
Effect.catchIf(
(cause): cause is StopStreaming => cause instanceof StopStreaming,
() => Effect.void,
this.withRequestPolicy(
client.DispatchStream(new DispatchPayload(input)).pipe(
Stream.runForEach((chunk) =>
Effect.suspend(() => {
last = chunk;
if (receiver(chunk)) return Effect.fail(new StopStreaming());
return Effect.void;
}),
),
Effect.catchIf(
(cause): cause is StopStreaming => cause instanceof StopStreaming,
() => Effect.void,
),
),
options,
),
);
return last;
@ -158,6 +172,27 @@ export class EffectRpcClient {
listener(state);
}
}
private withRequestPolicy<A, E, R>(
effect: Effect.Effect<A, E, R>,
options: DispatchOptions,
): Effect.Effect<A, E | DispatchError, R> {
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
const retryTimes = normalizeAttempts(options.retries) - 1;
const timed = effect.pipe(
Effect.timeoutOrElse({
duration: timeoutMs,
orElse: () =>
Effect.fail(
new DispatchError({
message: `Request timed out after ${timeoutMs}ms`,
}),
),
}),
);
return retryTimes > 0 ? timed.pipe(Effect.retry({ times: retryTimes })) : timed;
}
}
class StopStreaming extends Data.TaggedError("StopStreaming")<{}> {}
@ -190,3 +225,17 @@ function errorMessage(cause: unknown): string {
}
return String(cause);
}
function normalizeTimeoutMs(timeoutMs: number | undefined): number {
if (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return DEFAULT_REQUEST_TIMEOUT_MS;
}
return Math.floor(timeoutMs);
}
function normalizeAttempts(retries: number | undefined): number {
if (retries === undefined || !Number.isFinite(retries)) {
return DEFAULT_REQUEST_ATTEMPTS;
}
return Math.max(1, Math.floor(retries));
}

View file

@ -1,6 +1,11 @@
// Import core types and classes for the TrustGraph API
import type { Term, Triple } from "../models/Triple.js";
import { EffectRpcClient, type DispatchInput, type RpcConnectionState } from "./effect-rpc-client.js";
import {
EffectRpcClient,
type DispatchInput,
type DispatchOptions,
type RpcConnectionState,
} from "./effect-rpc-client.js";
import { getDefaultSocketUrl, getRandomValues } from "./websocket-adapter.js";
// Import all message types for different services
@ -120,6 +125,18 @@ function toErrorMessage(value: unknown, fallback: string): string {
return fallback;
}
function dispatchOptions(
timeoutMs: number | undefined,
retries: number | undefined,
): DispatchOptions {
const options: DispatchOptions = {};
if (timeoutMs !== undefined) {
return retries === undefined ? { timeoutMs } : { timeoutMs, retries };
}
if (retries !== undefined) return { retries };
return options;
}
function streamingMetadataFrom(source: {
in_token?: number;
out_token?: number;
@ -427,13 +444,15 @@ export class BaseApi {
makeRequest<RequestType extends object, ResponseType>(
service: string,
request: RequestType,
_timeout?: number,
_retries?: number,
timeout?: number,
retries?: number,
flow?: string,
) {
return this.rpc.dispatch(this.dispatchInput(service, request, flow)).then((obj) => {
return obj as ResponseType;
});
return this.rpc
.dispatch(this.dispatchInput(service, request, flow), dispatchOptions(timeout, retries))
.then((obj) => {
return obj as ResponseType;
});
}
/**
@ -444,15 +463,21 @@ export class BaseApi {
service: string,
request: RequestType,
receiver: (resp: unknown) => boolean, // Callback to handle each response chunk
_timeout?: number,
_retries?: number,
timeout?: number,
retries?: number,
flow?: string,
) {
return this.rpc.dispatchStream(this.dispatchInput(service, request, flow), (chunk) => {
return receiver({ response: chunk.response, complete: chunk.complete });
}).then((obj) => {
return obj as ResponseType;
});
return this.rpc
.dispatchStream(
this.dispatchInput(service, request, flow),
(chunk) => {
return receiver({ response: chunk.response, complete: chunk.complete });
},
dispatchOptions(timeout, retries),
)
.then((obj) => {
return obj as ResponseType;
});
}
/**