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
4 changes: 2 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ import type {
import { Asset } from "@stellar/stellar-sdk";
import { rolloverInvoice as _rolloverInvoice } from "./invoiceRollover.js";
import { BatchedRpcClient } from "./requestBatcher.js";
import { TimeoutManager, withTimeout } from "./timeout.js";
import { TimeoutManager, withTimeoutOrThrow } from "./timeout.js";
import type { TimeoutConfig } from "./timeout.js";
import { RequestTimeoutError } from "./errors.js";
import { TraceIdManager } from "./traceId.js";
Expand Down Expand Up @@ -1485,7 +1485,7 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
opts?.timeout ?? this._timeoutManager?.resolveTimeout(method);

if (timeoutMs !== undefined) {
return withTimeout(() => run(), timeoutMs, method);
return withTimeoutOrThrow(() => run(), timeoutMs, method);
}
return run();
}
Expand Down
55 changes: 54 additions & 1 deletion src/degradation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,65 @@ export interface DegradationConfig {
enabled: boolean;
}

export interface ServiceDegradationConfig {
failureThreshold: number;
recoveryWindowMs: number;
now?: () => number;
}

export type ServiceState = "healthy" | "degraded";

interface PendingEntry {
fn: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}

export class ServiceDegradationTracker {
private readonly failureThreshold: number;
private readonly recoveryWindowMs: number;
private readonly now: () => number;
private failureCount = 0;
private degradedAt: number | null = null;

constructor(config: ServiceDegradationConfig) {
this.failureThreshold = config.failureThreshold;
this.recoveryWindowMs = config.recoveryWindowMs;
this.now = config.now ?? Date.now;
}

recordFailure(): void {
this.refreshState();
this.failureCount++;
if (this.failureCount >= this.failureThreshold && this.degradedAt === null) {
this.degradedAt = this.now();
}
}

recordSuccess(): void {
this.refreshState();
if (this.degradedAt === null) {
this.failureCount = 0;
}
}

getState(): ServiceState {
this.refreshState();
return this.degradedAt === null ? "healthy" : "degraded";
}

private refreshState(): void {
if (this.degradedAt === null) {
return;
}

if (this.now() - this.degradedAt >= this.recoveryWindowMs) {
this.degradedAt = null;
this.failureCount = 0;
}
}
}

export class DegradationManager {
private _cache = new Map<string, unknown>();
private _queue: PendingEntry[] = [];
Expand Down Expand Up @@ -65,4 +118,4 @@ export class DegradationManager {
}
this._draining = false;
}
}
}
12 changes: 12 additions & 0 deletions src/healthDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ export interface SDKHealthSnapshot extends SDKHealth {
horizonProbe: HorizonProbeResult | null;
}

export type ServiceHealthStatus = "healthy" | "degraded" | "down";

export function aggregateServiceHealth(statuses: ServiceHealthStatus[]): ServiceHealthStatus {
if (statuses.some((status) => status === "down")) {
return "down";
}
if (statuses.some((status) => status === "degraded")) {
return "degraded";
}
return "healthy";
}

export async function getSDKHealth(): Promise<SDKHealthSnapshot> {
const latencyStart = Date.now();
let rpcLatency = 0;
Expand Down
4 changes: 3 additions & 1 deletion src/idempotency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createHash } from "crypto";
export interface IdempotencyConfig {
/** Duration (ms) to remember completed keys. Default: 300_000 (5 min). */
ttlMs?: number;
/** Preferred alias for ttlMs. Defaults to 24 hours when neither value is provided. */
keyTtlMs?: number;
/** Max entries in the key store before evicting oldest. Default: 10_000. */
maxEntries?: number;
}
Expand All @@ -18,7 +20,7 @@ export class IdempotencyManager {
private readonly maxEntries: number;

constructor(config?: IdempotencyConfig) {
this.ttlMs = config?.ttlMs ?? 300_000;
this.ttlMs = config?.keyTtlMs ?? config?.ttlMs ?? 86_400_000;
this.maxEntries = config?.maxEntries ?? 10_000;
}

Expand Down
34 changes: 32 additions & 2 deletions src/timeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export class RequestTimeoutError extends Error {
}
}

export type TimeoutResult<T> =
| { ok: true; value: T }
| { ok: false; reason: "timeout" | "error"; error?: Error };

const KNOWN_METHODS = [
"getInvoice",
"createInvoice",
Expand Down Expand Up @@ -94,7 +98,7 @@ export async function withTimeout<T>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
method: string
): Promise<T> {
): Promise<TimeoutResult<T>> {
const controller = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | undefined;

Expand All @@ -106,12 +110,38 @@ export async function withTimeout<T>(
});

try {
return await Promise.race([fn(controller.signal), timeoutPromise]);
const value = await Promise.race([fn(controller.signal), timeoutPromise]);
return { ok: true, value };
} catch (error) {
if (error instanceof RequestTimeoutError) {
return { ok: false, reason: "timeout", error };
}
return {
ok: false,
reason: "error",
error: error instanceof Error ? error : new Error(String(error)),
};
} finally {
clearTimeout(timeoutId);
}
}

/**
* @deprecated Use withTimeout() and inspect the TimeoutResult union.
*/
export async function withTimeoutOrThrow<T>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
method: string
): Promise<T> {
const result = await withTimeout(fn, timeoutMs, method);
if (result.ok) {
return result.value;
}

throw result.error ?? new Error(`withTimeoutOrThrow failed for ${method}`);
}

// ---------------------------------------------------------------------------
// EscalationManager — pre-deadline escalation actions
// ---------------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions test/degradation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { ServiceDegradationTracker } from "../src/degradation.js";

describe("ServiceDegradationTracker", () => {
it("reverts to healthy after the recovery window expires", () => {
let now = 0;
const tracker = new ServiceDegradationTracker({
failureThreshold: 2,
recoveryWindowMs: 1000,
now: () => now,
});

tracker.recordFailure();
tracker.recordFailure();
expect(tracker.getState()).toBe("degraded");

now = 1001;
expect(tracker.getState()).toBe("healthy");
});
});
16 changes: 16 additions & 0 deletions test/healthDashboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { aggregateServiceHealth } from "../src/healthDashboard.js";

describe("aggregateServiceHealth", () => {
it("returns healthy when all services are healthy", () => {
expect(aggregateServiceHealth(["healthy", "healthy"])).toBe("healthy");
});

it("returns degraded when one service is degraded and none are down", () => {
expect(aggregateServiceHealth(["healthy", "degraded", "healthy"])).toBe("degraded");
});

it("returns down when any service is down", () => {
expect(aggregateServiceHealth(["healthy", "down", "degraded"])).toBe("down");
});
});
12 changes: 11 additions & 1 deletion test/idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,24 @@ describe("IdempotencyManager", () => {
});

it("evicts expired entries", () => {
const shortManager = new IdempotencyManager({ ttlMs: -1 });
const shortManager = new IdempotencyManager({ keyTtlMs: -1 });
const key = shortManager.generateKey("GABC", "op-xdr-1");
shortManager.tryClaim(key, { txHash: "hash-1" });

const result = shortManager.getResult(key);
expect(result).toBeNull();
});

it("treats an expired key as a new request", () => {
const shortManager = new IdempotencyManager({ keyTtlMs: -1 });
const key = shortManager.generateKey("GABC", "op-xdr-1");

shortManager.tryClaim(key, { txHash: "hash-1" });
const second = shortManager.tryClaim(key, { txHash: "hash-2" });

expect(second.duplicate).toBe(false);
});

it("evicts oldest entry when at max capacity", () => {
const smallManager = new IdempotencyManager({ ttlMs: 300_000, maxEntries: 2 });

Expand Down
41 changes: 29 additions & 12 deletions test/timeout.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { TimeoutManager, withTimeout, RequestTimeoutError } from "../src/timeout.js";
import { TimeoutManager, withTimeout, withTimeoutOrThrow, RequestTimeoutError } from "../src/timeout.js";

afterEach(() => {
vi.useRealTimers();
Expand Down Expand Up @@ -40,22 +40,25 @@ describe("TimeoutManager", () => {
describe("withTimeout", () => {
it("resolves when operation completes within timeout", async () => {
const result = await withTimeout(async () => "ok", 1_000, "test");
expect(result).toBe("ok");
expect(result).toEqual({ ok: true, value: "ok" });
});

it("throws RequestTimeoutError when operation exceeds timeout", async () => {
it("returns a timeout result when operation exceeds timeout", async () => {
vi.useFakeTimers();

const slow = new Promise<never>(() => { /* never resolves */ });
const race = withTimeout(() => slow, 100, "slowMethod");

vi.advanceTimersByTime(150);

await expect(race).rejects.toThrow(RequestTimeoutError);
await expect(race).rejects.toMatchObject({ method: "slowMethod", timeoutMs: 100 });
await expect(race).resolves.toMatchObject({
ok: false,
reason: "timeout",
error: expect.objectContaining({ method: "slowMethod", timeoutMs: 100 }),
});
});

it("aborts and throws correctly; error has method and timeoutMs", async () => {
it("surfaces timeout metadata in the result union", async () => {
vi.useFakeTimers();

const race = withTimeout(
Expand All @@ -65,22 +68,36 @@ describe("withTimeout", () => {
);
vi.advanceTimersByTime(100);

const err = await race.catch((e) => e);
expect(err).toBeInstanceOf(RequestTimeoutError);
expect(err.method).toBe("getLeaderboard");
expect(err.timeoutMs).toBe(50);
expect(err.code).toBe("REQUEST_TIMEOUT");
const result = await race;
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("Expected timeout result");
}
expect(result.error).toBeInstanceOf(RequestTimeoutError);
expect(result.error).toMatchObject({ method: "getLeaderboard", timeoutMs: 50, code: "REQUEST_TIMEOUT" });
});

it("clears the timer when operation resolves fast", async () => {
vi.useFakeTimers();
const result = await withTimeout(async () => 42, 5_000, "fast");
expect(result).toBe(42);
expect(result).toEqual({ ok: true, value: 42 });
// No dangling timer — fake timers would expose it if cleanup failed
vi.runAllTimers();
});
});

describe("withTimeoutOrThrow", () => {
it("preserves the throwing behavior for existing callers", async () => {
vi.useFakeTimers();

const slow = new Promise<never>(() => undefined);
const race = withTimeoutOrThrow(() => slow, 100, "slowMethod");
vi.advanceTimersByTime(150);

await expect(race).rejects.toThrow(RequestTimeoutError);
});
});

describe("RequestTimeoutError", () => {
it("is an instance of Error", () => {
const err = new RequestTimeoutError("myMethod", 500);
Expand Down