@@ -20,6 +20,7 @@ export interface RegisterWebhookInput {
2020 events : WebhookEventType [ ] ;
2121 secretKey : string ;
2222 retryPolicy ?: Partial < WebhookRetryPolicy > ;
23+ rateLimitPerMinute ?: number ;
2324 isPaused ?: boolean ;
2425}
2526
@@ -94,6 +95,7 @@ export class WebhookDeliveryService {
9495 private readonly webhooks = new Map < string , WebhookConfig > ( ) ;
9596 private readonly deliveries = new Map < string , WebhookDelivery > ( ) ;
9697 private readonly deliveredKeys = new Set < string > ( ) ;
98+ private readonly rateLimitWindows = new Map < string , number [ ] > ( ) ;
9799
98100 constructor ( options : { fetchImpl ?: FetchLike ; sleepImpl ?: ( ms : number ) => Promise < void > } = { } ) {
99101 this . fetchImpl = options . fetchImpl ?? fetch ;
@@ -110,6 +112,7 @@ export class WebhookDeliveryService {
110112 events : [ ...input . events ] ,
111113 secretKey : input . secretKey ,
112114 retryPolicy : clampRetryPolicy ( input . retryPolicy ) ,
115+ rateLimitPerMinute : input . rateLimitPerMinute ,
113116 isPaused : input . isPaused ?? false ,
114117 createdAt,
115118 updatedAt : createdAt ,
@@ -136,6 +139,7 @@ export class WebhookDeliveryService {
136139 events : input . events ? [ ...input . events ] : existing . events ,
137140 secretKey : input . secretKey ?? existing . secretKey ,
138141 retryPolicy : clampRetryPolicy ( input . retryPolicy ?? existing . retryPolicy ) ,
142+ rateLimitPerMinute : input . rateLimitPerMinute ?? existing . rateLimitPerMinute ,
139143 isPaused : input . isPaused ?? existing . isPaused ,
140144 updatedAt : now ( ) ,
141145 } ;
@@ -193,6 +197,9 @@ export class WebhookDeliveryService {
193197 const avgAttempts = totalDeliveries
194198 ? deliveries . reduce ( ( sum , d ) => sum + d . attempts , 0 ) / totalDeliveries
195199 : 0 ;
200+ const latencySamples = deliveries
201+ . map ( ( delivery ) => delivery . latencyMs )
202+ . filter ( ( latency ) : latency is number => typeof latency === 'number' ) ;
196203
197204 return {
198205 webhookId,
@@ -203,6 +210,9 @@ export class WebhookDeliveryService {
203210 pendingDeliveries,
204211 successRate : totalDeliveries ? successfulDeliveries / totalDeliveries : 0 ,
205212 avgAttempts,
213+ avgLatencyMs : latencySamples . length
214+ ? latencySamples . reduce ( ( sum , latency ) => sum + latency , 0 ) / latencySamples . length
215+ : 0 ,
206216 lastSuccessAt : deliveries
207217 . filter ( ( delivery ) => delivery . status === 'delivered' && delivery . deliveredAt )
208218 . map ( ( delivery ) => delivery . deliveredAt as number )
@@ -270,6 +280,28 @@ export class WebhookDeliveryService {
270280 return { delivery } ;
271281 }
272282
283+ if ( this . isRateLimited ( webhook ) ) {
284+ const delivery : WebhookDelivery = {
285+ id : createId ( 'del' ) ,
286+ webhookId : webhook . id ,
287+ eventId : payload . id ,
288+ eventType : payload . eventType ,
289+ url : webhook . url ,
290+ payload,
291+ status : 'retrying' ,
292+ attempts : 0 ,
293+ maxAttempts : webhook . retryPolicy . maxRetries ,
294+ createdAt : now ( ) ,
295+ updatedAt : now ( ) ,
296+ signature,
297+ idempotencyKey,
298+ errorMessage : 'Webhook endpoint rate limited' ,
299+ nextRetryAt : now ( ) + 60_000 ,
300+ } ;
301+ this . deliveries . set ( delivery . id , delivery ) ;
302+ return { delivery } ;
303+ }
304+
273305 const delivery : WebhookDelivery = {
274306 id : createId ( 'del' ) ,
275307 webhookId : webhook . id ,
@@ -375,6 +407,7 @@ export class WebhookDeliveryService {
375407 status : 'delivered' ,
376408 responseCode : response . status ,
377409 deliveredAt : now ( ) ,
410+ latencyMs : now ( ) - attemptAt ,
378411 } ,
379412 response
380413 ) ;
@@ -442,6 +475,21 @@ export class WebhookDeliveryService {
442475 const rawDelay = Math . floor ( policy . initialDelayMs * Math . pow ( factor , Math . max ( 0 , attempt - 1 ) ) ) ;
443476 return Math . min ( rawDelay , policy . maxDelayMs ) ;
444477 }
478+
479+ private isRateLimited ( webhook : WebhookConfig ) : boolean {
480+ if ( ! webhook . rateLimitPerMinute || webhook . rateLimitPerMinute <= 0 ) return false ;
481+ const windowStart = now ( ) - 60_000 ;
482+ const current = ( this . rateLimitWindows . get ( webhook . id ) ?? [ ] ) . filter (
483+ ( timestamp ) => timestamp >= windowStart
484+ ) ;
485+ if ( current . length >= webhook . rateLimitPerMinute ) {
486+ this . rateLimitWindows . set ( webhook . id , current ) ;
487+ return true ;
488+ }
489+ current . push ( now ( ) ) ;
490+ this . rateLimitWindows . set ( webhook . id , current ) ;
491+ return false ;
492+ }
445493}
446494
447495export const webhookDeliveryService = new WebhookDeliveryService ( ) ;
0 commit comments