@@ -37,8 +37,66 @@ const MAX_URLS = 1000
3737// than paginate through a huge change set.
3838const COMPARE_FILE_LIMIT = 300
3939
40- // How many purge requests to keep in flight at once.
41- const PURGE_CONCURRENCY = 10
40+ // How many purge requests to keep in flight at once. Fastly rate-limits URL
41+ // purges (documented average: 100,000/customer/hour ~= 27/sec, with a stricter
42+ // undocumented burst limit). Keep this low and pair it with per-worker spacing
43+ // below so we stay well under the burst ceiling and stop tripping HTTP 429s.
44+ const PURGE_CONCURRENCY = 3
45+
46+ // Minimum spacing between consecutive purge requests within a single worker.
47+ // With PURGE_CONCURRENCY workers this caps the steady-state rate at roughly
48+ // (concurrency / spacing) req/sec: 3 / 0.15s ~= 20/sec, comfortably under the
49+ // documented 27/sec average.
50+ const PURGE_THROTTLE_MS = 150
51+
52+ // When Fastly still rate-limits us (HTTP 429), retry the individual URL this
53+ // many times before giving up on it.
54+ const PURGE_MAX_RATE_LIMIT_RETRIES = 5
55+
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.
60+ const PURGE_RATE_LIMIT_BASE_DELAY_MS = 1000
61+ const PURGE_RATE_LIMIT_MAX_DELAY_MS = 30_000
62+
63+ // A wall-clock ceiling for the whole targeted-purge phase. If Fastly is
64+ // rate-limiting us systemically (not just a one-off burst), the per-URL 429
65+ // retries above could otherwise stretch this non-blocking job out for hours.
66+ // Once we pass this deadline we stop starting new purges and fail loudly, so
67+ // the workflow's failure alerting fires and the routine soft-purge-all (which
68+ // always runs) remains the backstop for cache freshness.
69+ const PURGE_TIME_BUDGET_MS = 5 * 60_000
70+
71+ function sleep ( ms : number ) : Promise < void > {
72+ return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) )
73+ }
74+
75+ // How long to wait before retrying a rate-limited (429) purge. Prefers Fastly's
76+ // 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].
79+ export function rateLimitDelayMs ( response : Response , attempt : number ) : number {
80+ const clamp = ( ms : number ) => Math . min ( Math . max ( 0 , ms ) , PURGE_RATE_LIMIT_MAX_DELAY_MS )
81+
82+ const retryAfter = response . headers . get ( 'retry-after' )
83+ if ( retryAfter ) {
84+ const seconds = Number ( retryAfter )
85+ if ( Number . isFinite ( seconds ) ) return clamp ( seconds * 1000 )
86+ const dateMs = Date . parse ( retryAfter )
87+ if ( ! Number . isNaN ( dateMs ) ) return clamp ( dateMs - Date . now ( ) )
88+ }
89+
90+ const reset = response . headers . get ( 'fastly-ratelimit-reset' )
91+ if ( reset ) {
92+ const resetSeconds = Number ( reset )
93+ if ( Number . isFinite ( resetSeconds ) ) return clamp ( resetSeconds * 1000 - Date . now ( ) )
94+ }
95+
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 )
99+ }
42100
43101type ChangedFile = {
44102 filename : string
@@ -162,18 +220,33 @@ async function loadEnglishPermalinkIndex(): Promise<Map<string, string[]>> {
162220// https://www.fastly.com/documentation/reference/api/purging/#purge-a-url
163221async function hardPurgeUrl ( url : string , fastlyToken : string ) : Promise < void > {
164222 const withoutScheme = url . replace ( / ^ h t t p s ? : \/ \/ / , '' )
165- const response = await fetchWithRetry (
166- `https://api.fastly.com/purge/${ withoutScheme } ` ,
167- {
168- method : 'POST' ,
169- headers : {
170- 'fastly-key' : fastlyToken ,
171- accept : 'application/json' ,
223+ for ( let attempt = 0 ; ; attempt ++ ) {
224+ const response = await fetchWithRetry (
225+ `https://api.fastly.com/purge/${ withoutScheme } ` ,
226+ {
227+ method : 'POST' ,
228+ headers : {
229+ 'fastly-key' : fastlyToken ,
230+ accept : 'application/json' ,
231+ } ,
172232 } ,
173- } ,
174- { retries : 3 , timeout : 30_000 , throwHttpErrors : false } ,
175- )
176- if ( ! response . ok ) {
233+ { retries : 3 , timeout : 30_000 , throwHttpErrors : false } ,
234+ )
235+ if ( response . ok ) return
236+
237+ // Fastly rate limit. fetchWithRetry doesn't retry 429 when throwHttpErrors
238+ // is false, so back off and retry the URL ourselves, honoring Fastly's hint.
239+ if ( response . status === 429 && attempt < PURGE_MAX_RATE_LIMIT_RETRIES ) {
240+ const waitMs = rateLimitDelayMs ( response , attempt )
241+ console . warn (
242+ `Fastly rate-limited purge of ${ url } ; retrying in ${ waitMs } ms (attempt ${
243+ attempt + 1
244+ } /${ PURGE_MAX_RATE_LIMIT_RETRIES } )`,
245+ )
246+ await sleep ( waitMs )
247+ continue
248+ }
249+
177250 let body = ''
178251 try {
179252 body = await response . text ( )
@@ -195,12 +268,24 @@ export async function hardPurgeUrls(
195268 urls : string [ ] ,
196269 fastlyToken : string ,
197270 concurrency = PURGE_CONCURRENCY ,
271+ throttleMs = PURGE_THROTTLE_MS ,
272+ budgetMs = PURGE_TIME_BUDGET_MS ,
198273) : Promise < void > {
199274 const queue = [ ...urls ]
200275 const errors : Error [ ] = [ ]
276+ const deadline = Date . now ( ) + budgetMs
201277
202278 async function worker ( ) {
279+ let first = true
203280 while ( queue . length ) {
281+ // Space out requests within a worker so concurrency * (1/spacing) stays
282+ // under Fastly's burst limit. Skip the wait before the first request.
283+ if ( ! first && throttleMs > 0 ) await sleep ( throttleMs )
284+ first = false
285+ // Re-check the budget *after* the throttle sleep so the sleep can't push a
286+ // purge past the deadline; stop starting new purges once we're out of time
287+ // (see PURGE_TIME_BUDGET_MS). Anything left in the queue is reported below.
288+ if ( Date . now ( ) > deadline ) break
204289 const url = queue . shift ( )
205290 if ( ! url ) break
206291 try {
@@ -215,6 +300,11 @@ export async function hardPurgeUrls(
215300
216301 await Promise . all ( Array . from ( { length : Math . min ( concurrency , urls . length ) } , worker ) )
217302
303+ // Anything still queued means we hit the time budget before draining it.
304+ for ( const url of queue ) {
305+ errors . push ( new Error ( `Fastly URL purge skipped (time budget exceeded) for ${ url } ` ) )
306+ }
307+
218308 if ( errors . length ) {
219309 throw new Error ( `${ errors . length } of ${ urls . length } URL purge(s) failed` )
220310 }
0 commit comments