- daily-evaluation nutzt die Jahre 2011/2012 statt 2001, damit der Job nicht die Daten von autopilot und reminders liest und verändert. - throttler: app.listen(0) einmal je App statt eines Servers je Anfrage (ECONNRESET bei 40 parallelen Anfragen). - outbox-claims: 120 s Zeitlimit für 2000 sequentielle Anweisungen. - migration-harness: eine `migrate deploy` zur Zeit über eine eigene Advisory-Sperre (P1002 bei parallelen Migrations-Suites). - authority-notices und authority-notices-herd laufen über exclusive-suite nacheinander; Herd-Wiederholung räumt eigene Entwürfe. Refs #32 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
227 lines
7.6 KiB
TypeScript
227 lines
7.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
||
import { INestApplication } from '@nestjs/common';
|
||
import request from 'supertest';
|
||
import { App } from 'supertest/types';
|
||
import { PrismaThrottlerStorage } from '../src/common/throttler/prisma-throttler.storage';
|
||
import { PrismaService } from '../src/prisma/prisma.service';
|
||
import { createTestApp } from './utils/create-test-app';
|
||
|
||
// Public batches allow 30 requests per minute and client network.
|
||
const PUBLIC_BATCH_LIMIT = 30;
|
||
const PARALLEL_REQUESTS = 40;
|
||
|
||
describe('Database-backed throttler (e2e)', () => {
|
||
const suffix = randomUUID();
|
||
const path = `/api/v1/public/batches/NONE-${suffix}`;
|
||
const namespaces: string[] = [];
|
||
const apps: INestApplication<App>[] = [];
|
||
let prisma: PrismaService;
|
||
|
||
const newApp = async (
|
||
throttlerNamespace: string,
|
||
): Promise<INestApplication<App>> => {
|
||
namespaces.push(throttlerNamespace);
|
||
const app = await createTestApp({ throttlerNamespace });
|
||
// Listen once: otherwise supertest opens a throwaway server per request
|
||
// and 40 parallel ones reset connections (ECONNRESET) on a loaded CI box.
|
||
await app.listen(0);
|
||
apps.push(app);
|
||
prisma = app.get(PrismaService);
|
||
return app;
|
||
};
|
||
|
||
const status = async (app: INestApplication<App>): Promise<number> =>
|
||
(await request(app.getHttpServer()).get(path)).status;
|
||
|
||
afterAll(async () => {
|
||
for (const ns of namespaces) {
|
||
await prisma.throttlerHit.deleteMany({
|
||
where: { key: { startsWith: `${ns}:` } },
|
||
});
|
||
}
|
||
for (const app of apps) await app.close();
|
||
});
|
||
|
||
it('counts across two app instances sharing one namespace', async () => {
|
||
const ns = randomUUID();
|
||
const [a, b] = [await newApp(ns), await newApp(ns)];
|
||
|
||
for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) {
|
||
await request((i % 2 ? a : b).getHttpServer())
|
||
.get(path)
|
||
.expect(404);
|
||
}
|
||
|
||
await request(a.getHttpServer()).get(path).expect(429);
|
||
await request(b.getHttpServer()).get(path).expect(429);
|
||
});
|
||
|
||
it('40 parallel requests over two instances yield exactly 10 × 429', async () => {
|
||
const ns = randomUUID();
|
||
const [a, b] = [await newApp(ns), await newApp(ns)];
|
||
|
||
const statuses = await Promise.all(
|
||
Array.from({ length: PARALLEL_REQUESTS }, (_, i) =>
|
||
status(i % 2 ? a : b),
|
||
),
|
||
);
|
||
|
||
expect(statuses.filter((s) => s === 429)).toHaveLength(
|
||
PARALLEL_REQUESTS - PUBLIC_BATCH_LIMIT,
|
||
);
|
||
expect(statuses.filter((s) => s === 404)).toHaveLength(PUBLIC_BATCH_LIMIT);
|
||
});
|
||
|
||
it('separate namespaces never share counters', async () => {
|
||
const [a, b] = [await newApp(randomUUID()), await newApp(randomUUID())];
|
||
|
||
for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) {
|
||
await request(a.getHttpServer()).get(path).expect(404);
|
||
}
|
||
await request(a.getHttpServer()).get(path).expect(429);
|
||
|
||
await request(b.getHttpServer()).get(path).expect(404);
|
||
});
|
||
|
||
it('an expired window starts again at 1', async () => {
|
||
const ns = randomUUID();
|
||
await newApp(ns);
|
||
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
|
||
|
||
for (let i = 0; i < 3; i += 1) {
|
||
await storage.increment('window', 60000, 10, 60000, 'default');
|
||
}
|
||
// The window ends in the past, decided by the database clock.
|
||
await prisma.$executeRaw`
|
||
UPDATE "ThrottlerHit"
|
||
SET "expiresAt" = now() - interval '1 second'
|
||
WHERE "key" = ${`${ns}:window`}`;
|
||
|
||
const record = await storage.increment(
|
||
'window',
|
||
60000,
|
||
10,
|
||
60000,
|
||
'default',
|
||
);
|
||
|
||
expect(record.totalHits).toBe(1);
|
||
expect(record.isBlocked).toBe(false);
|
||
expect(record.timeToExpire).toBeGreaterThan(0);
|
||
expect(record.timeToExpire).toBeLessThanOrEqual(60);
|
||
});
|
||
|
||
it('never reports more seconds than the ttl or the block duration (sub-ms rounding)', async () => {
|
||
const ns = randomUUID();
|
||
await newApp(ns);
|
||
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
|
||
|
||
// The column keeps milliseconds while now() has microseconds; rounding
|
||
// up by < 1 ms used to turn 60.0004 s into 61. About half of all
|
||
// timestamps round up, so 60 fresh keys catch it reliably.
|
||
for (let i = 0; i < 60; i += 1) {
|
||
const record = await storage.increment(`ms-${i}`, 60000, 0, 60000, 'd');
|
||
expect(record.timeToExpire).toBeGreaterThanOrEqual(59);
|
||
expect(record.timeToExpire).toBeLessThanOrEqual(60);
|
||
expect(record.isBlocked).toBe(true);
|
||
expect(record.timeToBlockExpire).toBeGreaterThanOrEqual(59);
|
||
expect(record.timeToBlockExpire).toBeLessThanOrEqual(60);
|
||
}
|
||
});
|
||
|
||
it('keeps the same bounds when a window restarts or a block starts on an existing row', async () => {
|
||
const ns = randomUUID();
|
||
await newApp(ns);
|
||
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
|
||
const sleep = (ms: number): Promise<void> =>
|
||
new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
for (let i = 0; i < 60; i += 1) {
|
||
// Restart branch: the 1 ms window is over, the row starts again at 1.
|
||
await storage.increment(`re-${i}`, 1, 10, 60000, 'd');
|
||
await sleep(5);
|
||
const restarted = await storage.increment(
|
||
`re-${i}`,
|
||
60000,
|
||
10,
|
||
60000,
|
||
'd',
|
||
);
|
||
expect(restarted.totalHits).toBe(1);
|
||
expect(restarted.timeToExpire).toBeGreaterThanOrEqual(59);
|
||
expect(restarted.timeToExpire).toBeLessThanOrEqual(60);
|
||
|
||
// Block branch: hits + 1 > limit on an open window sets blockedUntil.
|
||
await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd');
|
||
const blocked = await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd');
|
||
expect(blocked.isBlocked).toBe(true);
|
||
expect(blocked.timeToBlockExpire).toBeGreaterThanOrEqual(59);
|
||
expect(blocked.timeToBlockExpire).toBeLessThanOrEqual(60);
|
||
}
|
||
});
|
||
|
||
it('keeps hits while blocked and releases the block after it expired', async () => {
|
||
const ns = randomUUID();
|
||
await newApp(ns);
|
||
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
|
||
|
||
await storage.increment('block', 60000, 1, 60000, 'default');
|
||
const blocked = await storage.increment(
|
||
'block',
|
||
60000,
|
||
1,
|
||
60000,
|
||
'default',
|
||
);
|
||
expect(blocked.isBlocked).toBe(true);
|
||
expect(blocked.totalHits).toBe(2);
|
||
expect(blocked.timeToBlockExpire).toBeGreaterThan(0);
|
||
|
||
const stillBlocked = await storage.increment(
|
||
'block',
|
||
60000,
|
||
1,
|
||
60000,
|
||
'default',
|
||
);
|
||
expect(stillBlocked.isBlocked).toBe(true);
|
||
expect(stillBlocked.totalHits).toBe(2);
|
||
|
||
await prisma.$executeRaw`
|
||
UPDATE "ThrottlerHit"
|
||
SET "blockedUntil" = now() - interval '1 second'
|
||
WHERE "key" = ${`${ns}:block`}`;
|
||
|
||
const released = await storage.increment(
|
||
'block',
|
||
60000,
|
||
1,
|
||
60000,
|
||
'default',
|
||
);
|
||
expect(released.isBlocked).toBe(false);
|
||
expect(released.totalHits).toBe(1);
|
||
expect(released.timeToBlockExpire).toBe(0);
|
||
});
|
||
|
||
it('blocks the first hit when the limit is 0', async () => {
|
||
const ns = randomUUID();
|
||
await newApp(ns);
|
||
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
|
||
|
||
const first = await storage.increment('zero', 60000, 0, 60000, 'default');
|
||
expect(first.isBlocked).toBe(true);
|
||
expect(first.totalHits).toBe(1);
|
||
|
||
// Same after a window or block has expired.
|
||
await prisma.$executeRaw`
|
||
UPDATE "ThrottlerHit"
|
||
SET "expiresAt" = now() - interval '1 second',
|
||
"blockedUntil" = now() - interval '1 second'
|
||
WHERE "key" = ${`${ns}:zero`}`;
|
||
const again = await storage.increment('zero', 60000, 0, 60000, 'default');
|
||
expect(again.isBlocked).toBe(true);
|
||
expect(again.totalHits).toBe(1);
|
||
});
|
||
});
|