fix(housekeeping): contain provider failures
This commit is contained in:
1 parent
4c399873d1
commit
ca666d9f90
3 files changed
+152
-2
No files matched your search
@@ -66,6 +66,68 @@ describe("orchestrateProviders", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a successful sibling when another provider throws synchronously", async () => {
|
||||||
|
const synchronousFailure: HousekeepingProvider<Item> = {
|
||||||
|
id: "synchronously-broken",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: () => {
|
||||||
|
throw new Error("synchronous dependency failure");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await orchestrateProviders(
|
||||||
|
[
|
||||||
|
synchronousFailure,
|
||||||
|
provider("fast", [{ key: "fast", label: "Fast", order: 1 }]),
|
||||||
|
],
|
||||||
|
allowedContext,
|
||||||
|
policy,
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
items: [{ key: "fast", label: "Fast", order: 1 }],
|
||||||
|
errors: [{ providerId: "synchronously-broken", code: "INTERNAL" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the timeout result and sibling success when a provider rejects late", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let observedAbort = false;
|
||||||
|
const lateFailure: HousekeepingProvider<Item> = {
|
||||||
|
id: "late-failure",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: async (_context, signal) =>
|
||||||
|
await new Promise<never>((_resolve, reject) => {
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
observedAbort = true;
|
||||||
|
});
|
||||||
|
setTimeout(() => reject(new Error("late dependency failure")), 2_001);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const batch = orchestrateProviders(
|
||||||
|
[
|
||||||
|
lateFailure,
|
||||||
|
provider("fast", [{ key: "fast", label: "Fast", order: 1 }]),
|
||||||
|
],
|
||||||
|
allowedContext,
|
||||||
|
policy,
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
|
||||||
|
expect(await batch).toMatchObject({
|
||||||
|
items: [{ key: "fast", label: "Fast", order: 1 }],
|
||||||
|
errors: [{ providerId: "late-failure", code: "TIMEOUT" }],
|
||||||
|
});
|
||||||
|
expect(observedAbort).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(await batch).toMatchObject({
|
||||||
|
items: [{ key: "fast", label: "Fast", order: 1 }],
|
||||||
|
errors: [{ providerId: "late-failure", code: "TIMEOUT" }],
|
||||||
|
});
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the first duplicate before applying deterministic sorting", async () => {
|
it("keeps the first duplicate before applying deterministic sorting", async () => {
|
||||||
const result = await orchestrateProviders(
|
const result = await orchestrateProviders(
|
||||||
[
|
[
|
||||||
@@ -108,4 +170,20 @@ describe("orchestrateProviders", () => {
|
|||||||
{ key: "one", label: "One", order: 3 },
|
{ key: "one", label: "One", order: 3 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies the per-provider cap before cross-provider deduplication", async () => {
|
||||||
|
const result = await orchestrateProviders(
|
||||||
|
[
|
||||||
|
provider("first", [{ key: "shared", label: "First", order: 1 }]),
|
||||||
|
provider("second", [
|
||||||
|
{ key: "shared", label: "Second", order: 2 },
|
||||||
|
{ key: "unique", label: "Unique", order: 3 },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
allowedContext,
|
||||||
|
{ ...policy, perProviderLimit: 1, combinedLimit: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toEqual([{ key: "shared", label: "First", order: 1 }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
anyCapability,
|
anyCapability,
|
||||||
|
fail,
|
||||||
type HousekeepingCapabilityContext,
|
type HousekeepingCapabilityContext,
|
||||||
ok,
|
ok,
|
||||||
} from "../contracts";
|
} from "../contracts";
|
||||||
@@ -75,4 +76,75 @@ describe("runProvider", () => {
|
|||||||
await runProvider(provider, context(["housekeeping.read"]), 2_000),
|
await runProvider(provider, context(["housekeeping.read"]), 2_000),
|
||||||
).toEqual({ providerId: "broken", items: [], error: "INTERNAL" });
|
).toEqual({ providerId: "broken", items: [], error: "INTERNAL" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps a synchronous provider throw to one INTERNAL partial error", async () => {
|
||||||
|
const provider: HousekeepingProvider<string> = {
|
||||||
|
id: "synchronously-broken",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: () => {
|
||||||
|
throw new Error("synchronous dependency failure");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runProvider(provider, context(["housekeeping.read"]), 2_000),
|
||||||
|
).resolves.toEqual({
|
||||||
|
providerId: "synchronously-broken",
|
||||||
|
items: [],
|
||||||
|
error: "INTERNAL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out an abort-ignoring provider exactly at 2,000 ms", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let observedAbort = false;
|
||||||
|
const provider: HousekeepingProvider<string> = {
|
||||||
|
id: "abort-ignoring",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: async (_context, signal) => {
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
observedAbort = true;
|
||||||
|
});
|
||||||
|
return await new Promise<never>(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runProvider(provider, context(["housekeeping.read"]), 2_000);
|
||||||
|
let settled = false;
|
||||||
|
void result.then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(1_999);
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
expect(await result).toEqual({
|
||||||
|
providerId: "abort-ignoring",
|
||||||
|
items: [],
|
||||||
|
error: "TIMEOUT",
|
||||||
|
});
|
||||||
|
expect(observedAbort).toBe(true);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a returned typed provider failure code once", async () => {
|
||||||
|
const provider: HousekeepingProvider<string> = {
|
||||||
|
id: "dependency-failure",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: async () =>
|
||||||
|
fail(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"errors.housekeeping.dependencyUnavailable",
|
||||||
|
"provider-correlation",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await runProvider(provider, context(["housekeeping.read"]), 2_000),
|
||||||
|
).toEqual({
|
||||||
|
providerId: "dependency-failure",
|
||||||
|
items: [],
|
||||||
|
error: "DEPENDENCY_UNAVAILABLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -32,8 +32,8 @@ export async function runProvider<T>(
|
|||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
const run = provider
|
const run = Promise.resolve()
|
||||||
.run(context, controller.signal)
|
.then(() => provider.run(context, controller.signal))
|
||||||
.then(
|
.then(
|
||||||
(result): ProviderRunResult<T> =>
|
(result): ProviderRunResult<T> =>
|
||||||
result.ok
|
result.ok
|
||||||
|
|||||||
Reference in new issue
Block a user