fix(api): the API failed to start after moving the proposal limit to Redis
ProposalLimiter, in the presenters, asked for the RateLimitStore port, which only the application layer sees, so Nest could not build it and the container never became healthy. The count now lives in an application service, ProposalQuota; the presenter only answers 429. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { ProposalQuota } from './proposal-quota.service';
|
||||
import { RateLimitStore } from './ports/rate-limit.store';
|
||||
|
||||
class MemoryStore extends RateLimitStore {
|
||||
readonly counts = new Map<string, number>();
|
||||
async hit(key: string): Promise<number> {
|
||||
const count = (this.counts.get(key) ?? 0) + 1;
|
||||
this.counts.set(key, count);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProposalQuota', () => {
|
||||
it('lets five proposals through per address, then says no', async () => {
|
||||
const quota = new ProposalQuota(new MemoryStore());
|
||||
const answers = [];
|
||||
for (let i = 0; i < 6; i++) answers.push(await quota.take('1.2.3.4'));
|
||||
|
||||
expect(answers).toEqual([true, true, true, true, true, false]);
|
||||
expect(await quota.take('5.6.7.8')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RateLimitStore } from './ports/rate-limit.store';
|
||||
|
||||
/** Proposals from one address within the window. */
|
||||
const LIMIT = 5;
|
||||
const WINDOW_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* Keeps one address from flooding a project with proposals. Counted in
|
||||
* Redis, so the limit holds across every instance of the API.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProposalQuota {
|
||||
constructor(private readonly store: RateLimitStore) {}
|
||||
|
||||
/** Counts one more proposal from `address`; false once it is over. */
|
||||
async take(address: string): Promise<boolean> {
|
||||
const count = await this.store.hit(`proposals:${address}`, WINDOW_MS);
|
||||
return count <= LIMIT;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { ReleasesService } from './releases.service';
|
||||
import { SAGAS } from './sagas/sagas';
|
||||
import { WorkflowsService } from './workflows.service';
|
||||
import { BlockersService } from './blockers.service';
|
||||
import { ProposalQuota } from './proposal-quota.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { Notifier } from './notifications/notifier';
|
||||
import { NotificationsEventHandler } from './notifications/notifications.event-handler';
|
||||
@@ -69,6 +70,7 @@ const SERVICES = [
|
||||
WebhooksService,
|
||||
TransferService,
|
||||
AutomationsService,
|
||||
ProposalQuota,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user