@@ -53,10 +53,14 @@ const PURGE_THROTTLE_MS = 150
5353// many times before giving up on it.
5454const PURGE_MAX_RATE_LIMIT_RETRIES = 5
5555
56- // Backoff bounds used when a 429 response carries no usable Retry-After /
57- // Fastly-RateLimit-Reset hint. Exponential from BASE, capped at MAX. MAX also
58- // caps any server-provided delay so a worker can't hang for the full rate-limit
59- // window (up to an hour) on a single URL.
56+ // Backoff bounds for retrying a rate-limited (429) purge. Additive (linear)
57+ // growth from BASE per attempt, capped at MAX. Fastly's rate-limit window resets
58+ // on the order of a second, so a URL just needs to wait for the next window, not
59+ // escalate exponentially toward a minutes-long wait. This backoff also acts as a
60+ // floor under any server-provided hint (see rateLimitDelayMs): a hint that
61+ // resolves to ~0 must not collapse the retry to 0ms. MAX also caps any
62+ // server-provided delay so a worker can't hang for the full rate-limit window
63+ // (up to an hour) on a single URL.
6064const PURGE_RATE_LIMIT_BASE_DELAY_MS = 1000
6165const PURGE_RATE_LIMIT_MAX_DELAY_MS = 30_000
6266
@@ -74,28 +78,42 @@ function sleep(ms: number): Promise<void> {
7478
7579// How long to wait before retrying a rate-limited (429) purge. Prefers Fastly's
7680// own hints (Retry-After in seconds or as an HTTP date; else Fastly-RateLimit-
77- // Reset as a Unix timestamp), falling back to exponential backoff with jitter.
78- // The result is clamped to [0, PURGE_RATE_LIMIT_MAX_DELAY_MS].
81+ // Reset as a Unix timestamp), but floors that hint at the additive backoff so a
82+ // stale or current-second reset (which computes to <= 0) can't produce a 0ms
83+ // retry that just re-hammers the limiter. Jitter is always added to decorrelate
84+ // concurrent workers that saw the same reset timestamp. The result is clamped to
85+ // [0, PURGE_RATE_LIMIT_MAX_DELAY_MS].
7986export function rateLimitDelayMs ( response : Response , attempt : number ) : number {
8087 const clamp = ( ms : number ) => Math . min ( Math . max ( 0 , ms ) , PURGE_RATE_LIMIT_MAX_DELAY_MS )
8188
89+ const backoff = PURGE_RATE_LIMIT_BASE_DELAY_MS * ( attempt + 1 )
90+ const hintMs = serverRetryHintMs ( response ) ?? 0
91+ const jitter = Math . floor ( Math . random ( ) * PURGE_THROTTLE_MS )
92+
93+ // Honor the larger of Fastly's hint and our backoff floor, then always add
94+ // jitter so concurrent workers that saw the same reset timestamp don't wake in
95+ // lockstep and re-burst.
96+ return clamp ( Math . max ( hintMs , backoff ) + jitter )
97+ }
98+
99+ // Fastly's suggested wait from a 429 response, in ms, or null if it sends no
100+ // usable hint. Can be negative (stale) or zero; callers must floor it.
101+ function serverRetryHintMs ( response : Response ) : number | null {
82102 const retryAfter = response . headers . get ( 'retry-after' )
83103 if ( retryAfter ) {
84104 const seconds = Number ( retryAfter )
85- if ( Number . isFinite ( seconds ) ) return clamp ( seconds * 1000 )
105+ if ( Number . isFinite ( seconds ) ) return seconds * 1000
86106 const dateMs = Date . parse ( retryAfter )
87- if ( ! Number . isNaN ( dateMs ) ) return clamp ( dateMs - Date . now ( ) )
107+ if ( ! Number . isNaN ( dateMs ) ) return dateMs - Date . now ( )
88108 }
89109
90110 const reset = response . headers . get ( 'fastly-ratelimit-reset' )
91111 if ( reset ) {
92112 const resetSeconds = Number ( reset )
93- if ( Number . isFinite ( resetSeconds ) ) return clamp ( resetSeconds * 1000 - Date . now ( ) )
113+ if ( Number . isFinite ( resetSeconds ) ) return resetSeconds * 1000 - Date . now ( )
94114 }
95115
96- const backoff = PURGE_RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt
97- const jitter = Math . floor ( Math . random ( ) * PURGE_THROTTLE_MS )
98- return clamp ( backoff + jitter )
116+ return null
99117}
100118
101119type ChangedFile = {
@@ -218,7 +236,11 @@ async function loadEnglishPermalinkIndex(): Promise<Map<string, string[]>> {
218236// scoped) and uses only the Fastly-Key. Omitting the soft-purge header makes it
219237// a hard purge: the object is evicted, so the next request is a fresh miss.
220238// https://www.fastly.com/documentation/reference/api/purging/#purge-a-url
221- async function hardPurgeUrl ( url : string , fastlyToken : string ) : Promise < void > {
239+ async function hardPurgeUrl (
240+ url : string ,
241+ fastlyToken : string ,
242+ rateLimitDelayFn : ( response : Response , attempt : number ) => number = rateLimitDelayMs ,
243+ ) : Promise < void > {
222244 const withoutScheme = url . replace ( / ^ h t t p s ? : \/ \/ / , '' )
223245 for ( let attempt = 0 ; ; attempt ++ ) {
224246 const response = await fetchWithRetry (
@@ -237,7 +259,7 @@ async function hardPurgeUrl(url: string, fastlyToken: string): Promise<void> {
237259 // Fastly rate limit. fetchWithRetry doesn't retry 429 when throwHttpErrors
238260 // is false, so back off and retry the URL ourselves, honoring Fastly's hint.
239261 if ( response . status === 429 && attempt < PURGE_MAX_RATE_LIMIT_RETRIES ) {
240- const waitMs = rateLimitDelayMs ( response , attempt )
262+ const waitMs = rateLimitDelayFn ( response , attempt )
241263 console . warn (
242264 `Fastly rate-limited purge of ${ url } ; retrying in ${ waitMs } ms (attempt ${
243265 attempt + 1
@@ -270,6 +292,7 @@ export async function hardPurgeUrls(
270292 concurrency = PURGE_CONCURRENCY ,
271293 throttleMs = PURGE_THROTTLE_MS ,
272294 budgetMs = PURGE_TIME_BUDGET_MS ,
295+ rateLimitDelayFn : ( response : Response , attempt : number ) => number = rateLimitDelayMs ,
273296) : Promise < void > {
274297 const queue = [ ...urls ]
275298 const errors : Error [ ] = [ ]
@@ -290,7 +313,7 @@ export async function hardPurgeUrls(
290313 if ( ! url ) break
291314 try {
292315 console . log ( `Hard-purging ${ url } ` )
293- await hardPurgeUrl ( url , fastlyToken )
316+ await hardPurgeUrl ( url , fastlyToken , rateLimitDelayFn )
294317 } catch ( error ) {
295318 console . error ( error )
296319 errors . push ( error instanceof Error ? error : new Error ( String ( error ) ) )
0 commit comments