From 1383e15f3f6d24157308c7653d07cdd099c7251e Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Tue, 13 Oct 2026 21:01:38 +0200 Subject: [PATCH] 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 --- libs/api/tracker/application/src/index.ts | 1 + .../src/lib/proposal-quota.service.spec.ts | 22 +++++++++++++++++++ .../src/lib/proposal-quota.service.ts | 21 ++++++++++++++++++ .../src/lib/tracker-application.module.ts | 2 ++ .../src/lib/http/proposal-limiter.ts | 16 ++++---------- 5 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/proposal-quota.service.spec.ts create mode 100644 libs/api/tracker/application/src/lib/proposal-quota.service.ts diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 85cfcfb..c598e51 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/releases.service'; export * from './lib/changes.service'; export * from './lib/workflows.service'; export * from './lib/blockers.service'; +export * from './lib/proposal-quota.service'; export * from './lib/notifications.service'; export * from './lib/notifications'; export * from './lib/saved-filters.service'; diff --git a/libs/api/tracker/application/src/lib/proposal-quota.service.spec.ts b/libs/api/tracker/application/src/lib/proposal-quota.service.spec.ts new file mode 100644 index 0000000..7600cb9 --- /dev/null +++ b/libs/api/tracker/application/src/lib/proposal-quota.service.spec.ts @@ -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(); + async hit(key: string): Promise { + 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); + }); +}); diff --git a/libs/api/tracker/application/src/lib/proposal-quota.service.ts b/libs/api/tracker/application/src/lib/proposal-quota.service.ts new file mode 100644 index 0000000..fc2b7a9 --- /dev/null +++ b/libs/api/tracker/application/src/lib/proposal-quota.service.ts @@ -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 { + const count = await this.store.hit(`proposals:${address}`, WINDOW_MS); + return count <= LIMIT; + } +} diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 2df214e..364cf3d 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -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, ]; /** diff --git a/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts b/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts index 300e16b..0c07c6e 100644 --- a/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts +++ b/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts @@ -1,21 +1,13 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { RateLimitStore } from '@task-stream/api/tracker/application'; +import { ProposalQuota } from '@task-stream/api/tracker/application'; -/** 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. - */ +/** The proposal quota, as the HTTP answer: 429 once it is used up. */ @Injectable() export class ProposalLimiter { - constructor(private readonly store: RateLimitStore) {} + constructor(private readonly quota: ProposalQuota) {} async take(address: string): Promise { - const count = await this.store.hit(`proposals:${address}`, WINDOW_MS); - if (count > LIMIT) + if (!(await this.quota.take(address))) throw new HttpException( 'Too many proposals from here; try again in a few minutes', HttpStatus.TOO_MANY_REQUESTS,