mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* chore: auto-sync from VM - 2026-07-10T16:29:57Z * fix(combo): retry transient errors in pipeline strategy Pipeline combo strategy (sequential chain) was hard-failing on ANY intermediate step error, including transient ones like 429 rate-limit and 503 service-unavailable. The combo config already exposes maxRetries and retryDelayMs, but handlePipelineChat() ignored them entirely — a single 429 from the first provider would kill the whole pipeline without trying the remaining steps. Now intermediate steps that fail with a transient HTTP status (429, 502, 503, 504) are retried up to maxRetries times with retryDelayMs delay, mirroring the retry behaviour already used by priority/weighted strategies. Non-transient errors (400, 401, 403, 404) still fail immediately. Changes: - open-sse/services/pipeline.ts: add maxRetries/retryDelayMs params, retry loop for transient statuses - open-sse/services/combo.ts: wire combo.config.maxRetries and combo.config.retryDelayMs to handlePipelineChat() - tests/unit/combo-pipeline.test.ts: 7 tests covering retry success, retry exhaustion, non-transient skip, final-step passthrough, backward compat (maxRetries=0) * chore: revert unrelated package-lock.json scope creep from PR #7794 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(changelog): add fragment for #7794 pipeline transient retry Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Andrian B. <andrewbalanesq@gmail.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
199 lines
6.3 KiB
TypeScript
199 lines
6.3 KiB
TypeScript
/**
|
|
* Combo Pipeline Strategy Tests
|
|
*
|
|
* Tests for open-sse/services/pipeline.ts — the sequential chain combo strategy.
|
|
* Focus: transient retry behaviour (429/502/503/504) added to prevent hard-fail
|
|
* on rate-limited or temporarily unavailable upstream providers.
|
|
*/
|
|
|
|
import { describe, it, mock } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { handlePipelineChat, type PipelineStep } from "../../open-sse/services/pipeline.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types & helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Body = Record<string, unknown>;
|
|
|
|
interface MockResponse {
|
|
ok: boolean;
|
|
status: number;
|
|
json: () => Promise<unknown>;
|
|
clone: () => MockResponse;
|
|
}
|
|
|
|
/** Build a successful OpenAI-shaped response with given text. */
|
|
function okResponse(text: string): MockResponse {
|
|
const body = {
|
|
choices: [{ message: { role: "assistant", content: text } }],
|
|
};
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => body,
|
|
// handlePipelineChat calls res.clone().json() on intermediate steps
|
|
clone: () => okResponse(text),
|
|
};
|
|
}
|
|
|
|
/** Build a failed response with given status. */
|
|
function failResponse(status: number): MockResponse {
|
|
const body = { error: { message: `HTTP ${status}` } };
|
|
return {
|
|
ok: false,
|
|
status,
|
|
json: async () => body,
|
|
clone: () => failResponse(status),
|
|
};
|
|
}
|
|
|
|
type HandlerFn = (body: Body, model: string) => Promise<MockResponse>;
|
|
|
|
/** Build a mock handleSingleModel that returns responses in sequence. */
|
|
function makeHandler(responses: MockResponse[], opts?: { loopLast?: boolean }): HandlerFn {
|
|
let call = 0;
|
|
return async (_body: Body, _model: string): Promise<MockResponse> => {
|
|
const idx = call++;
|
|
if (idx < responses.length) return responses[idx];
|
|
if (opts?.loopLast && responses.length > 0) return responses[responses.length - 1];
|
|
return okResponse("fallback");
|
|
};
|
|
}
|
|
|
|
// Minimal stub type — the real type is more complex but we only need (body, model) => Response
|
|
// (kept for reference, not used as value)
|
|
|
|
const noopLog = {
|
|
info: () => {},
|
|
warn: () => {},
|
|
error: () => {},
|
|
debug: () => {},
|
|
};
|
|
|
|
const STEPS: PipelineStep[] = [
|
|
{ model: "provider-a/model-a" },
|
|
{ model: "provider-b/model-b" },
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("handlePipelineChat — transient retry", () => {
|
|
it("succeeds when all steps return 200", async () => {
|
|
const handler = makeHandler([okResponse("step 1 output"), okResponse("step 2 output")]);
|
|
const res = await handlePipelineChat({
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
steps: STEPS,
|
|
handleSingleModel: handler as unknown as never,
|
|
log: noopLog as never,
|
|
maxRetries: 2,
|
|
});
|
|
assert.equal(res.ok, true);
|
|
});
|
|
|
|
it("retries on 429 then succeeds", async () => {
|
|
// First call to step 1 → 429, second call to step 1 → 200, step 2 → 200
|
|
const handler = makeHandler([
|
|
failResponse(429),
|
|
okResponse("recovered"),
|
|
okResponse("final"),
|
|
]);
|
|
const res = await handlePipelineChat({
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
steps: STEPS,
|
|
handleSingleModel: handler as unknown as never,
|
|
log: noopLog as never,
|
|
maxRetries: 2,
|
|
retryDelayMs: 1, // fast for tests
|
|
});
|
|
assert.equal(res.ok, true);
|
|
});
|
|
|
|
it("retries on 503 then succeeds", async () => {
|
|
const handler = makeHandler([
|
|
failResponse(503),
|
|
failResponse(503),
|
|
okResponse("recovered after 2"),
|
|
okResponse("final"),
|
|
]);
|
|
const res = await handlePipelineChat({
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
steps: STEPS,
|
|
handleSingleModel: handler as unknown as never,
|
|
log: noopLog as never,
|
|
maxRetries: 2,
|
|
retryDelayMs: 1,
|
|
});
|
|
assert.equal(res.ok, true);
|
|
});
|
|
|
|
it("fails after exhausting retries on persistent 429", async () => {
|
|
// Step 1 always returns 429, even after maxRetries=2 (3 total attempts)
|
|
const handler = makeHandler([failResponse(429)], { loopLast: true });
|
|
const res = await handlePipelineChat({
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
steps: STEPS,
|
|
handleSingleModel: handler as unknown as never,
|
|
log: noopLog as never,
|
|
maxRetries: 2,
|
|
retryDelayMs: 1,
|
|
});
|
|
assert.equal(res.ok, false);
|
|
assert.equal(res.status, 429);
|
|
});
|
|
|
|
it("fails immediately on 400 (non-transient, no retry)", async () => {
|
|
let callCount = 0;
|
|
const handler = async (): Promise<MockResponse> => {
|
|
callCount++;
|
|
return failResponse(400);
|
|
};
|
|
const res = await handlePipelineChat({
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
steps: STEPS,
|
|
handleSingleModel: handler as unknown as never,
|
|
log: noopLog as never,
|
|
maxRetries: 5, // should NOT retry on 400
|
|
retryDelayMs: 1,
|
|
});
|
|
assert.equal(res.ok, false);
|
|
assert.equal(res.status, 400);
|
|
// Only 1 call — no retry on non-transient error
|
|
assert.equal(callCount, 1);
|
|
});
|
|
|
|
it("does NOT retry the final step", async () => {
|
|
// Step 1 → 200, final step → 502 (should be returned as-is, no retry)
|
|
const handler = makeHandler([okResponse("step 1"), failResponse(502)], { loopLast: true });
|
|
const res = await handlePipelineChat({
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
steps: STEPS,
|
|
handleSingleModel: handler as unknown as never,
|
|
log: noopLog as never,
|
|
maxRetries: 3,
|
|
retryDelayMs: 1,
|
|
});
|
|
// Final step result is returned directly regardless of status
|
|
assert.equal(res.status, 502);
|
|
assert.equal(res.ok, false);
|
|
});
|
|
});
|
|
|
|
describe("handlePipelineChat — backward compat (no retry)", () => {
|
|
it("fails on transient error when maxRetries=0 (default)", async () => {
|
|
const handler = makeHandler([failResponse(429)], { loopLast: true });
|
|
const res = await handlePipelineChat({
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
steps: STEPS,
|
|
handleSingleModel: handler as unknown as never,
|
|
log: noopLog as never,
|
|
// maxRetries defaults to 0 — no retry
|
|
});
|
|
assert.equal(res.ok, false);
|
|
assert.equal(res.status, 429);
|
|
});
|
|
});
|