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
6 changes: 6 additions & 0 deletions tests/e2e/src/cases/concurrency-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProxyClient,
spawnApp,
startOpenAiUpstream,
awaitWindowHeadroom,
waitConfigPropagation,
type OpenAiUpstream,
type SpawnedApp,
Expand Down Expand Up @@ -122,6 +123,11 @@ describe("concurrency e2e: rate-limit isolation across callers", () => {
maxRetries: 0,
});

// The limiter buckets on fixed wall-clock minutes. Here the readiness
// probes themselves consume the RPM=1 slots, so probe AND assertion
// must land in the same window — ask for enough headroom to cover the
// propagation polling that follows.
await awaitWindowHeadroom(20);
// Readiness gate: caller A's first call succeeds.
await waitConfigPropagation(async () => {
try {
Expand Down
13 changes: 1 addition & 12 deletions tests/e2e/src/cases/model-group-member-ratelimit-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ProxyClient,
spawnApp,
startOpenAiUpstream,
awaitWindowHeadroom,
waitConfigPropagation,
type OpenAiUpstream,
type SpawnedApp,
Expand Down Expand Up @@ -191,18 +192,6 @@ describe("model group member rate limit e2e (AISIX-Cloud#1087)", () => {
return r.body.choices?.[0]?.message?.content ?? "";
}

/**
* Sleep until the current wall-clock minute has at least `headroomSecs`
* left. The limiter buckets on fixed wall-clock minutes
* (`window_start = now - now % 60`), so a burst that straddles a boundary
* silently gets a fresh quota and the failover/429 assertions would flap.
* Waiting for headroom keeps each burst inside one window.
*/
async function awaitWindowHeadroom(headroomSecs: number): Promise<void> {
const secondsLeft = 60 - (Math.floor(Date.now() / 1000) % 60);
if (secondsLeft >= headroomSecs) return;
await new Promise((r) => setTimeout(r, secondsLeft * 1000 + 100));
}

test("over-limit member fails over to the next target (RPM)", async (ctx) => {
if (!etcdReachable || !app) {
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/src/cases/passthrough-model-rate-limit-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SeedClient,
spawnApp,
startOpenAiUpstream,
awaitWindowHeadroom,
waitConfigPropagation,
type OpenAiUpstream,
type SpawnedApp,
Expand Down Expand Up @@ -138,6 +139,10 @@ describe("passthrough e2e: body-model rate limiting on the raw tunnel", () => {
return;
}

// The limiter buckets on fixed wall-clock minutes, so a burst that
// straddles a boundary gets a fresh allowance and the 429 assertion
// below flaps. Keep the whole burst inside one window.
await awaitWindowHeadroom();
// First submission consumes the model's single rpm slot.
const first = await callSynth(VIDEO_MODEL);
expect(first.status).toBe(200);
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProxyClient,
spawnApp,
startOpenAiUpstream,
awaitWindowHeadroom,
waitConfigPropagation,
type OpenAiUpstream,
type SpawnedApp,
Expand Down Expand Up @@ -153,6 +154,10 @@ describe("rate limit is shared across replicas with backend=redis (#798)", () =>
return;
}

// The limiter buckets on fixed wall-clock minutes, so a burst that
// straddles a boundary gets a fresh allowance and the 429 assertion
// below flaps. Keep the whole burst inside one window.
await awaitWindowHeadroom();
const first = await chatRequest(appA.proxyUrl, model);
expect(first.status).toBe(200);
await first.body?.cancel();
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/src/cases/ratelimit-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProxyClient,
spawnApp,
startOpenAiUpstream,
awaitWindowHeadroom,
waitConfigPropagation,
type OpenAiUpstream,
type SpawnedApp,
Expand Down Expand Up @@ -93,6 +94,10 @@ describe("rate limit e2e: RPM=1 second call gets 429", () => {
maxRetries: 0,
});

// The limiter buckets on fixed wall-clock minutes, so a burst that
// straddles a boundary gets a fresh allowance and the 429 assertion
// below flaps. Keep the whole burst inside one window.
await awaitWindowHeadroom();
// First call burns the only allowed slot.
const ok = await client.chat.completions.create({
model: "rl-e2e",
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/src/cases/team-member-ratelimit-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProxyClient,
spawnApp,
startOpenAiUpstream,
awaitWindowHeadroom,
waitConfigPropagation,
type OpenAiUpstream,
type SpawnedApp,
Expand Down Expand Up @@ -133,6 +134,10 @@ describe("rate limit e2e: team_member per-member default buckets", () => {
}
};

// The limiter buckets on fixed wall-clock minutes, so a burst that
// straddles a boundary gets a fresh allowance and the 429 assertion
// below flaps. Keep the whole burst inside one window.
await awaitWindowHeadroom();
// Member A burns their single slot, then is throttled.
expect(await callStatus(KEY_A1)).toBe(200);
expect(await callStatus(KEY_A1)).toBe(429);
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/src/cases/videos-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SeedClient,
spawnApp,
startOpenAiUpstream,
awaitWindowHeadroom,
waitConfigPropagation,
type OpenAiUpstream,
type SpawnedApp,
Expand Down Expand Up @@ -280,6 +281,10 @@ describe("videos e2e: unified submit/poll/content surface", () => {
return;
}

// The limiter buckets on fixed wall-clock minutes, so a burst that
// straddles a boundary gets a fresh allowance and the 429 assertion
// below flaps. Keep the whole burst inside one window.
await awaitWindowHeadroom();
// First submit consumes the model's single rpm slot.
const first = await submit(RL_MODEL);
expect(first.status).toBe(200);
Expand Down
22 changes: 22 additions & 0 deletions tests/e2e/src/harness/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,25 @@ export async function waitConfigPropagation(
}
throw new Error(`waitConfigPropagation: condition not met within ${timeoutMs}ms`);
}

/**
* Sleep until the current wall-clock minute has at least `headroomSecs`
* left.
*
* The rate limiter buckets on **fixed wall-clock windows** — see
* `roll_if_stale` in `crates/aisix-ratelimit/src/window.rs`, which
* computes `bucket_start = (now / window_secs) * window_secs`. A burst
* that straddles a boundary therefore lands in two different buckets and
* the later request silently gets a fresh allowance, so any "the next
* call must be 429" assertion flaps depending on when in the minute CI
* happened to run it.
*
* Call this immediately before a burst that must land inside one window.
* Nothing else in the test needs to change: the wait only happens in the
* last few seconds of a minute, so the usual run pays nothing.
*/
export async function awaitWindowHeadroom(headroomSecs = 10): Promise<void> {
const secondsLeft = 60 - (Math.floor(Date.now() / 1000) % 60);
if (secondsLeft >= headroomSecs) return;
await new Promise((r) => setTimeout(r, secondsLeft * 1000 + 100));
}
2 changes: 1 addition & 1 deletion tests/e2e/src/harness/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { spawnApp, type SpawnedApp, type AppOverrides } from "./app.js";
export { AdminClient, waitConfigPropagation } from "./admin.js";
export { AdminClient, waitConfigPropagation, awaitWindowHeadroom } from "./admin.js";
export { ProxyClient } from "./proxy.js";
export { EtcdClient } from "./etcd.js";
export { SeedClient } from "./seed.js";
Expand Down
65 changes: 65 additions & 0 deletions tests/e2e/src/harness/window-headroom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { awaitWindowHeadroom } from "./admin.js";

// The rate limiter buckets on fixed wall-clock windows — `roll_if_stale`
// in `crates/aisix-ratelimit/src/window.rs` computes
// `bucket_start = (now / window_secs) * window_secs`. A burst that
// straddles a boundary lands in two buckets, the later call gets a fresh
// allowance, and any "the next call must be 429" assertion flaps
// depending on when in the minute CI happened to run it.
//
// These pin the two branches with fake timers, because the real behaviour
// is only observable in the last few seconds of a wall-clock minute and
// would otherwise go untested on almost every run.

describe("awaitWindowHeadroom", () => {
afterEach(() => {
vi.useRealTimers();
});

test("returns immediately when the window has enough left", async () => {
vi.useFakeTimers();
// 10s into the minute → 50s of headroom.
vi.setSystemTime(new Date("2026-01-01T00:00:10.000Z"));

let settled = false;
const pending = awaitWindowHeadroom(10).then(() => {
settled = true;
});
// No timer advance at all: the helper must not have scheduled a wait.
await pending;
expect(settled).toBe(true);
});

test("waits past the boundary when the window is nearly over", async () => {
vi.useFakeTimers();
// 55s into the minute → only 5s left, less than the 10s asked for.
vi.setSystemTime(new Date("2026-01-01T00:00:55.000Z"));

let settled = false;
const pending = awaitWindowHeadroom(10).then(() => {
settled = true;
});

await vi.advanceTimersByTimeAsync(1_000);
expect(settled).toBe(false);

// 5s remaining + the helper's 100ms cushion puts us in the next window.
await vi.advanceTimersByTimeAsync(4_200);
await pending;
expect(settled).toBe(true);
});

test("treats the requested headroom as the threshold, not a sleep", async () => {
vi.useFakeTimers();
// Exactly at the threshold (30s left, 30s asked) → no wait.
vi.setSystemTime(new Date("2026-01-01T00:00:30.000Z"));

let settled = false;
const pending = awaitWindowHeadroom(30).then(() => {
settled = true;
});
await pending;
expect(settled).toBe(true);
});
});
Loading