From 73334e98cc4336038d7834e2d74f55fdefc4fe1b Mon Sep 17 00:00:00 2001 From: coredevdave-cmd Date: Thu, 27 Aug 2026 21:53:11 +0100 Subject: [PATCH 1/4] fix: recover degraded services on window expiry --- src/degradation.ts | 55 +++++++++++++++++++++++++++++++++++++++- test/degradation.test.ts | 20 +++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 test/degradation.test.ts diff --git a/src/degradation.ts b/src/degradation.ts index 31932dd..f902a16 100644 --- a/src/degradation.ts +++ b/src/degradation.ts @@ -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; 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(); private _queue: PendingEntry[] = []; @@ -65,4 +118,4 @@ export class DegradationManager { } this._draining = false; } -} \ No newline at end of file +} diff --git a/test/degradation.test.ts b/test/degradation.test.ts new file mode 100644 index 0000000..b0a7bd7 --- /dev/null +++ b/test/degradation.test.ts @@ -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"); + }); +}); From 41df14d2a91d9d78d40a08fb788938fa61bc9700 Mon Sep 17 00:00:00 2001 From: coredevdave-cmd Date: Thu, 27 Aug 2026 21:53:14 +0100 Subject: [PATCH 2/4] feat: expire idempotency keys lazily --- src/idempotency.ts | 4 +++- test/idempotency.test.ts | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/idempotency.ts b/src/idempotency.ts index c3324a8..e07583d 100644 --- a/src/idempotency.ts +++ b/src/idempotency.ts @@ -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; } @@ -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; } diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts index 8a344c0..da784ca 100644 --- a/test/idempotency.test.ts +++ b/test/idempotency.test.ts @@ -58,7 +58,7 @@ 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" }); @@ -66,6 +66,16 @@ describe("IdempotencyManager", () => { 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 }); From f303507ec753fbeb1798ab75c5d659edc42cb972 Mon Sep 17 00:00:00 2001 From: coredevdave-cmd Date: Thu, 27 Aug 2026 21:53:18 +0100 Subject: [PATCH 3/4] refactor: return typed timeout results --- src/client.ts | 4 ++-- src/timeout.ts | 34 ++++++++++++++++++++++++++++++++-- test/timeout.test.ts | 41 +++++++++++++++++++++++++++++------------ 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/src/client.ts b/src/client.ts index 31f7d8c..cbe59d2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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"; @@ -1485,7 +1485,7 @@ export class StellarSplitClient extends TypedEventEmitter { opts?.timeout ?? this._timeoutManager?.resolveTimeout(method); if (timeoutMs !== undefined) { - return withTimeout(() => run(), timeoutMs, method); + return withTimeoutOrThrow(() => run(), timeoutMs, method); } return run(); } diff --git a/src/timeout.ts b/src/timeout.ts index c2ab4bc..b422e5d 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -32,6 +32,10 @@ export class RequestTimeoutError extends Error { } } +export type TimeoutResult = + | { ok: true; value: T } + | { ok: false; reason: "timeout" | "error"; error?: Error }; + const KNOWN_METHODS = [ "getInvoice", "createInvoice", @@ -94,7 +98,7 @@ export async function withTimeout( fn: (signal: AbortSignal) => Promise, timeoutMs: number, method: string -): Promise { +): Promise> { const controller = new AbortController(); let timeoutId: ReturnType | undefined; @@ -106,12 +110,38 @@ export async function withTimeout( }); 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( + fn: (signal: AbortSignal) => Promise, + timeoutMs: number, + method: string +): Promise { + 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 // --------------------------------------------------------------------------- diff --git a/test/timeout.test.ts b/test/timeout.test.ts index e41b593..b3280d7 100644 --- a/test/timeout.test.ts +++ b/test/timeout.test.ts @@ -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(); @@ -40,10 +40,10 @@ 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 resolves */ }); @@ -51,11 +51,14 @@ describe("withTimeout", () => { 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( @@ -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(() => 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); From daf69c3ddebc06bfd8a553cee8ed731bb66ae0a1 Mon Sep 17 00:00:00 2001 From: coredevdave-cmd Date: Thu, 27 Aug 2026 21:53:21 +0100 Subject: [PATCH 4/4] test: cover health dashboard rollups --- src/healthDashboard.ts | 12 ++++++++++++ test/healthDashboard.test.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 test/healthDashboard.test.ts diff --git a/src/healthDashboard.ts b/src/healthDashboard.ts index f9c3201..b4a7734 100644 --- a/src/healthDashboard.ts +++ b/src/healthDashboard.ts @@ -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 { const latencyStart = Date.now(); let rpcLatency = 0; diff --git a/test/healthDashboard.test.ts b/test/healthDashboard.test.ts new file mode 100644 index 0000000..e112f4d --- /dev/null +++ b/test/healthDashboard.test.ts @@ -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"); + }); +});