Z ZPI 文档 v0.3.8
介绍页

源码

packages/ai/test/provider-retry.test.ts

typescript97 行查看原文
import { normalizeContext } from "@zpi-dev/ai";
import { streamSimple } from "@zpi-dev/ai/api/openai-completions";
import { afterEach, expect, it, vi } from "vitest";
import { chunk, done, fakeModel, fakeServer, send } from "../../../tests/fake-server.ts";
import { modelFailure } from "../src/utils/model-retry.ts";
import { retryProviderRequest } from "../src/utils/provider-retry.ts";

const cleanup: (() => Promise<void>)[] = [];
afterEach(async () => {
  vi.useRealTimers();
  vi.restoreAllMocks();
  await Promise.all(cleanup.splice(0).map((close) => close()));
});

it("retries HTTP 429 and 5xx before streaming, respects headers and never repeats partial output", async () => {
  let mode: "retry" | "bad" | "partial" = "retry";
  const server = await fakeServer((_, response, index) => {
    if (mode === "retry" && index < 2) {
      response.writeHead(index === 0 ? 429 : 503, {
        "content-type": "application/json",
        ...(index === 0 ? { "retry-after": "0.01" } : {}),
      });
      response.end(JSON.stringify({ error: { message: "try later" } }));
    } else if (mode === "bad") {
      response.writeHead(400, { "content-type": "application/json" });
      response.end(JSON.stringify({ error: { message: "bad input" } }));
    } else {
      send(response, chunk({ content: "once" }));
      if (mode === "partial") response.end();
      else done(response);
    }
  });
  cleanup.push(server.close);
  const request = () =>
    streamSimple(fakeModel(server.url), normalizeContext({ messages: [] }), { apiKey: "local" });
  const start = Date.now();
  const onRetry = vi.fn();
  const stream = streamSimple(fakeModel(server.url), normalizeContext({ messages: [] }), {
    apiKey: "local",
    onRetry,
  });
  const types: string[] = [];
  for await (const event of stream) types.push(event.type);
  expect((await stream.result()).stopReason).toBe("stop");
  expect(server.requests).toHaveLength(3);
  expect(types.filter((type) => type === "start")).toHaveLength(1);
  expect(Date.now() - start).toBeGreaterThanOrEqual(750);
  expect(onRetry.mock.calls).toEqual([
    [{ attempt: 1, maxRetries: 2, retryDelayMs: 10, errorStatus: 429 }],
    [{ attempt: 2, maxRetries: 2, retryDelayMs: expect.any(Number), errorStatus: 503 }],
    [null],
  ]);
  mode = "bad";
  expect((await request().result()).stopReason).toBe("error");
  expect(server.requests).toHaveLength(4);
  mode = "partial";
  const partial = await request().result();
  expect(partial.stopReason).toBe("error");
  expect(partial.content).toEqual([{ type: "text", text: "once" }]);
  expect(server.requests).toHaveLength(5);
});

it("uses server retry policy, bounded delay and interruptible backoff", async () => {
  vi.useFakeTimers();
  const error = (status: number | undefined, headers: Record<string, string> = {}) =>
    Object.assign(new Error("provider unavailable"), { status, headers: new Headers(headers) });
  const request = vi
    .fn()
    .mockRejectedValueOnce(
      error(400, { "x-should-retry": "true", "retry-after-ms": "100", "retry-after": "10" }),
    )
    .mockResolvedValue("ok");
  const result = retryProviderRequest(request, { maxRetries: 2 });
  await vi.advanceTimersByTimeAsync(99);
  expect(request).toHaveBeenCalledTimes(1);
  await vi.advanceTimersByTimeAsync(1);
  expect(await result).toBe("ok");

  const controller = new AbortController();
  const waiting = vi.fn().mockRejectedValue(error(undefined));
  const aborted = retryProviderRequest(waiting, { maxRetries: 2, signal: controller.signal });
  const assertion = expect(aborted).rejects.toMatchObject({ name: "AbortError" });
  await vi.advanceTimersByTimeAsync(1);
  controller.abort();
  await assertion;
  expect(waiting).toHaveBeenCalledTimes(1);

  const limited = vi.fn().mockRejectedValue(error(429, { "retry-after": "61" }));
  await expect(retryProviderRequest(limited, { maxRetries: 2 })).rejects.toThrow("max: 60s");
  expect(limited).toHaveBeenCalledTimes(1);
  const capped = await retryProviderRequest(limited, { maxRetries: 2 }).catch((failure: unknown) => failure);
  expect(modelFailure(capped)).toMatchObject({ status: 429, retryable: true, retryAfterMs: 61000 });
  const forbidden = vi.fn().mockRejectedValue(error(503, { "x-should-retry": "false" }));
  await expect(retryProviderRequest(forbidden, { maxRetries: 2 })).rejects.toThrow("provider unavailable");
  expect(forbidden).toHaveBeenCalledTimes(1);
});