mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
Stabilize TS workbench QA and RPC timeouts
This commit is contained in:
parent
3515106670
commit
952daf325d
9 changed files with 183 additions and 156 deletions
79
ts/packages/client/src/__tests__/rpc-timeout.test.ts
Normal file
79
ts/packages/client/src/__tests__/rpc-timeout.test.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue