From 82403b275a33330f4bb971e95cd8abe39d52d7f0 Mon Sep 17 00:00:00 2001 From: maikNeta Date: Sat, 17 Oct 2026 17:35:29 +0100 Subject: [PATCH] fix: add provider quota tracking to prevent payout retry exhausting quotas (Issue #1024) --- backend/src/services/payout-reliability.ts | 23 ++++ backend/src/services/quota.ts | 143 +++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 backend/src/services/quota.ts diff --git a/backend/src/services/payout-reliability.ts b/backend/src/services/payout-reliability.ts index eeeb5f9c..c2b291b6 100644 --- a/backend/src/services/payout-reliability.ts +++ b/backend/src/services/payout-reliability.ts @@ -1,7 +1,9 @@ // Issue #1155: Improve payout reliability (iteration 5) // Implements payout queue management with dead letter queue and health monitoring +// Issue #1024: Integrated quota tracking to prevent provider quota exhaustion import { EventEmitter } from 'events'; +import { quotaService } from './quota.js'; /** * Payout request structure @@ -12,6 +14,7 @@ export interface PayoutRequest { amount: string; currency: string; merchantId: string; + providerId?: string; createdAt: string; attempts: number; lastAttemptAt?: string; @@ -48,6 +51,8 @@ export interface PayoutReliabilityConfig { healthCheckIntervalMs: number; /** Concurrent payout processing limit */ concurrencyLimit: number; + /** Respect provider quotas (Issue #1024) */ + respectQuota?: boolean; } /** @@ -61,6 +66,7 @@ export const DEFAULT_PAYOUT_CONFIG: PayoutReliabilityConfig = { enableDeadLetterQueue: true, healthCheckIntervalMs: 60000, concurrencyLimit: 10, + respectQuota: true, }; /** @@ -162,11 +168,28 @@ export class PayoutReliabilityService extends EventEmitter { const startTime = Date.now(); try { + // Check provider quota before attempting (Issue #1024) + if (this.config.respectQuota && request.providerId) { + if (!quotaService.canMakeRequest(request.providerId)) { + await this.handleFailure(request, { + success: false, + error: `Provider quota exhausted for ${request.providerId}`, + retriable: true, + }); + return; + } + } + const result = await this.processor.process(request); const processingTime = Date.now() - startTime; this.recordProcessingTime(processingTime); + // Record quota usage (Issue #1024) + if (this.config.respectQuota && request.providerId) { + quotaService.recordRequest(request.providerId, request.attempts > 1); + } + if (result.success) { await this.handleSuccess(request, result); } else { diff --git a/backend/src/services/quota.ts b/backend/src/services/quota.ts new file mode 100644 index 00000000..bd911bbe --- /dev/null +++ b/backend/src/services/quota.ts @@ -0,0 +1,143 @@ +/** + * quota.ts — Issue #1024 + * + * Provider quota tracking and rate limiting to prevent exhausting + * provider quotas during failed payout retries. + */ + +export interface QuotaConfig { + maxRequestsPerHour: number; + maxRequestsPerDay: number; + burstLimit: number; +} + +export interface QuotaUsage { + providerId: string; + hourlyCount: number; + dailyCount: number; + lastResetHour: number; + lastResetDay: number; + burstRemaining: number; +} + +const DEFAULT_QUOTAS: Record = { + stripe: { + maxRequestsPerHour: 1000, + maxRequestsPerDay: 20000, + burstLimit: 50, + }, + stellar: { + maxRequestsPerHour: 500, + maxRequestsPerDay: 10000, + burstLimit: 25, + }, + solana: { + maxRequestsPerHour: 300, + maxRequestsPerDay: 7200, + burstLimit: 10, + }, +}; + +export class QuotaService { + private quotaUsage: Map = new Map(); + private quotaConfigs: Map = new Map(); + + constructor() { + this.initializeQuotas(); + } + + private initializeQuotas(): void { + for (const [provider, config] of Object.entries(DEFAULT_QUOTAS)) { + this.quotaConfigs.set(provider, config); + this.quotaUsage.set(provider, { + providerId: provider, + hourlyCount: 0, + dailyCount: 0, + lastResetHour: Date.now(), + lastResetDay: Date.now(), + burstRemaining: config.burstLimit, + }); + } + } + + private resetHourlyIfNeeded(usage: QuotaUsage, config: QuotaConfig): void { + const now = Date.now(); + const hourMs = 60 * 60 * 1000; + if (now - usage.lastResetHour > hourMs) { + usage.hourlyCount = 0; + usage.lastResetHour = now; + usage.burstRemaining = config.burstLimit; + } + } + + private resetDailyIfNeeded(usage: QuotaUsage): void { + const now = Date.now(); + const dayMs = 24 * 60 * 60 * 1000; + if (now - usage.lastResetDay > dayMs) { + usage.dailyCount = 0; + usage.lastResetDay = now; + } + } + + canMakeRequest(providerId: string, isBurst: boolean = false): boolean { + const config = this.quotaConfigs.get(providerId); + const usage = this.quotaUsage.get(providerId); + + if (!config || !usage) { + return true; + } + + this.resetHourlyIfNeeded(usage, config); + this.resetDailyIfNeeded(usage); + + if (isBurst && usage.burstRemaining > 0) { + return true; + } + + return ( + usage.hourlyCount < config.maxRequestsPerHour && + usage.dailyCount < config.maxRequestsPerDay + ); + } + + recordRequest(providerId: string, isBurst: boolean = false): void { + const usage = this.quotaUsage.get(providerId); + const config = this.quotaConfigs.get(providerId); + + if (!usage || !config) { + return; + } + + this.resetHourlyIfNeeded(usage, config); + this.resetDailyIfNeeded(usage); + + usage.hourlyCount++; + usage.dailyCount++; + + if (isBurst && usage.burstRemaining > 0) { + usage.burstRemaining--; + } + } + + getUsage(providerId: string): QuotaUsage | undefined { + return this.quotaUsage.get(providerId); + } + + setQuotaConfig(providerId: string, config: Partial): void { + const existing = this.quotaConfigs.get(providerId); + if (existing) { + this.quotaConfigs.set(providerId, { ...existing, ...config }); + } + } + + getQuotaConfig(providerId: string): QuotaConfig | undefined { + return this.quotaConfigs.get(providerId); + } + + resetAll(): void { + this.quotaUsage.clear(); + this.initializeQuotas(); + } +} + +export const quotaService = new QuotaService();