chore: raise the db memory limit and rate-limit social writes
Two smaller operational items. POSTGRES 512M -> 1G. DATABASE_MAX_CONNECTIONS is 30 for a ~100-guest event (feed polling + SSE + uploads at once), and 30 backends plus Postgres 16's default shared_buffers leaves very little headroom at 512M. An OOM here doesn't degrade one feature -- every request path touches the database, so it takes the event down. Memory is the cheaper knob than shrinking the pool back and reintroducing the queueing it was raised to fix. .env.example now names the pairing explicitly, the way it already does for COMPRESSION_WORKER_CONCURRENCY. SOCIAL WRITES WERE UNTHROTTLED. toggle_like, add_comment and delete_comment were the only mutating endpoints in the app with no limit at all -- upload, join, recover, export and admin login all carry one. Asymmetric coverage rather than a deliberate decision. Low severity, and honestly so: a like fans an SSE broadcast to every client, but the export regeneration a comment deletion triggers is contained (REGEN_DEBOUNCE 20s, workers born with their epoch, superseded ones inert). So the ceiling is 120/min -- far above anything a real guest produces. This bounds a script, not an enthusiastic double-tapper. ONE bucket across all three actions: separate buckets would let a caller triple the aggregate write rate by alternating between them. Keyed per USER, matching the feed and upload limits -- at a venue every guest is behind one NAT, and an IP key is what made the /join and /feed limits turn guests away in the first place. Migration 020 seeds both keys, and both are wired into the admin allowlist, the config UI and the e2e reseed -- the step two earlier per-area toggles missed, which left switches that existed in code and could never be flipped. Tests: 4 e2e, including that the shared bucket really is shared (the part most likely to be lost in a refactor) and that one guest hitting the ceiling doesn't block another behind the same IP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
136
e2e/specs/03-feed/social-rate-limit.spec.ts
Normal file
136
e2e/specs/03-feed/social-rate-limit.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Regression guard — likes, comments and comment deletions are rate limited.
|
||||
*
|
||||
* These were the only mutating endpoints in the app with no limit at all. Every other write path
|
||||
* -- upload, join, recover, export, admin login -- carried one; `social.rs` carried none, so the
|
||||
* coverage was asymmetric rather than deliberately open.
|
||||
*
|
||||
* Severity is genuinely low for an invited-guest event, and the amplification worry is contained:
|
||||
* a like does fan an SSE broadcast to every connected client, but the export regeneration a
|
||||
* comment deletion triggers is debounced (REGEN_DEBOUNCE 20s) and superseded workers are inert. So
|
||||
* this closes the gap for symmetry, and the ceiling is set well above anything a real guest
|
||||
* produces -- it bounds a script, not an enthusiastic double-tapper.
|
||||
*
|
||||
* The bucket is shared across all three actions on purpose: separate buckets would let a caller
|
||||
* triple the aggregate write rate just by alternating between them. That is what the second test
|
||||
* pins, and it is the part most likely to be lost in a refactor.
|
||||
*
|
||||
* Keyed per USER, not per IP — at a venue every guest is behind one NAT, so an IP key would hand
|
||||
* the whole party one bucket. Third test.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const like = (jwt: string, uploadId: string) =>
|
||||
fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
const comment = (jwt: string, uploadId: string, body: string) =>
|
||||
fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
|
||||
test.describe('Social — rate limit', () => {
|
||||
test('a burst of likes past the ceiling returns 429 with Retry-After', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
social_rate_enabled: 'true',
|
||||
social_rate_per_min: '3',
|
||||
});
|
||||
|
||||
const g = await guest('Tapper');
|
||||
const uploadId = await seedUpload(g.jwt);
|
||||
|
||||
// Sequential, not parallel: a toggle flips state, so ordering matters for the assertion.
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 5; i++) statuses.push((await like(g.jwt, uploadId)).status);
|
||||
|
||||
expect(statuses.slice(0, 3), 'the first three are within the ceiling').toEqual([200, 200, 200]);
|
||||
expect(statuses.slice(3), 'everything past it is refused').toEqual([429, 429]);
|
||||
|
||||
const limited = await like(g.jwt, uploadId);
|
||||
expect(limited.status).toBe(429);
|
||||
expect(
|
||||
limited.headers.get('retry-after'),
|
||||
'a 429 without Retry-After tells the client nothing about when to come back'
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('likes and comments share one bucket', async ({ api, adminToken, guest }) => {
|
||||
// THE assertion. Per-action buckets would let a caller triple the aggregate write rate by
|
||||
// alternating, which defeats the point of having a ceiling at all.
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
social_rate_enabled: 'true',
|
||||
social_rate_per_min: '2',
|
||||
});
|
||||
|
||||
const g = await guest('Mixer');
|
||||
const uploadId = await seedUpload(g.jwt);
|
||||
|
||||
expect((await like(g.jwt, uploadId)).status).toBe(200);
|
||||
expect((await comment(g.jwt, uploadId, 'schön!')).status).toBe(201);
|
||||
// Two writes spent, whichever endpoints they went to.
|
||||
expect(
|
||||
(await comment(g.jwt, uploadId, 'noch eins')).status,
|
||||
'a comment must consume the same budget a like does'
|
||||
).toBe(429);
|
||||
expect((await like(g.jwt, uploadId)).status).toBe(429);
|
||||
});
|
||||
|
||||
test('one guest hitting the ceiling does not block another', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
// Keyed per user, not per IP. Every request in this suite comes from one address, which is
|
||||
// exactly the venue-NAT shape that made the /join and /feed limits turn guests away.
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
social_rate_enabled: 'true',
|
||||
social_rate_per_min: '2',
|
||||
});
|
||||
|
||||
const noisy = await guest('Noisy');
|
||||
const quiet = await guest('Quiet');
|
||||
const uploadId = await seedUpload(noisy.jwt);
|
||||
|
||||
for (let i = 0; i < 3; i++) await like(noisy.jwt, uploadId);
|
||||
expect((await like(noisy.jwt, uploadId)).status).toBe(429);
|
||||
|
||||
expect(
|
||||
(await like(quiet.jwt, uploadId)).status,
|
||||
'a second guest behind the same IP must have their own budget'
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
test('flipping social_rate_enabled off bypasses the limit', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
// The toggle has to actually be honoured, or the admin switch is decorative — the failure
|
||||
// mode two other per-area toggles already shipped with.
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
social_rate_enabled: 'false',
|
||||
social_rate_per_min: '2',
|
||||
});
|
||||
|
||||
const g = await guest('Unlimited');
|
||||
const uploadId = await seedUpload(g.jwt);
|
||||
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 6; i++) statuses.push((await like(g.jwt, uploadId)).status);
|
||||
expect(statuses.every((s) => s === 200)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user