Skip to content

Commit 2fc6284

Browse files
heiskrCopilot
andauthored
Fix Fastly purge 0ms retry storm on 429 (#62274)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9c04fbb commit 2fc6284

2 files changed

Lines changed: 64 additions & 26 deletions

File tree

src/workflows/purge-fastly-changed-content.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,14 @@ const PURGE_THROTTLE_MS = 150
5353
// many times before giving up on it.
5454
const 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.
6064
const PURGE_RATE_LIMIT_BASE_DELAY_MS = 1000
6165
const 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].
7986
export 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

101119
type 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(/^https?:\/\//, '')
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)))

src/workflows/tests/purge-fastly-changed-content.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -199,15 +199,15 @@ describe('hardPurgeUrls', () => {
199199
fetchWithRetry
200200
.mockResolvedValueOnce(fakeResponse(429, { headers: { 'retry-after': '0' } }))
201201
.mockResolvedValueOnce(fakeResponse(200, { ok: true }))
202-
await hardPurgeUrls(['https://docs.github.com/en/foo'], 'tok', 1, 0)
202+
await hardPurgeUrls(['https://docs.github.com/en/foo'], 'tok', 1, 0, undefined, () => 0)
203203
expect(fetchWithRetry).toHaveBeenCalledTimes(2)
204204
})
205205

206206
test('gives up after the retry budget and reports the URL as failed', async () => {
207207
fetchWithRetry.mockResolvedValue(fakeResponse(429, { headers: { 'retry-after': '0' } }))
208-
await expect(hardPurgeUrls(['https://docs.github.com/en/foo'], 'tok', 1, 0)).rejects.toThrow(
209-
/1 of 1 URL purge\(s\) failed/,
210-
)
208+
await expect(
209+
hardPurgeUrls(['https://docs.github.com/en/foo'], 'tok', 1, 0, undefined, () => 0),
210+
).rejects.toThrow(/1 of 1 URL purge\(s\) failed/)
211211
// Initial attempt + 5 retries.
212212
expect(fetchWithRetry).toHaveBeenCalledTimes(6)
213213
})
@@ -244,10 +244,12 @@ describe('rateLimitDelayMs', () => {
244244
})
245245

246246
test('honors Retry-After given in seconds', () => {
247+
vi.spyOn(Math, 'random').mockReturnValue(0)
247248
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '5' }), 0)).toBe(5000)
248249
})
249250

250251
test('honors Retry-After given as an HTTP date', () => {
252+
vi.spyOn(Math, 'random').mockReturnValue(0)
251253
vi.useFakeTimers()
252254
const now = new Date('2026-01-01T00:00:00Z')
253255
vi.setSystemTime(now)
@@ -256,29 +258,42 @@ describe('rateLimitDelayMs', () => {
256258
})
257259

258260
test('honors Fastly-RateLimit-Reset as a Unix timestamp', () => {
261+
vi.spyOn(Math, 'random').mockReturnValue(0)
259262
vi.useFakeTimers()
260263
const now = new Date('2026-01-01T00:00:00Z')
261264
vi.setSystemTime(now)
262265
const reset = String(Math.floor(now.getTime() / 1000) + 7)
263266
expect(rateLimitDelayMs(fakeResponse({ 'fastly-ratelimit-reset': reset }), 0)).toBe(7000)
264267
})
265268

266-
test('falls back to exponential backoff when no hint is present', () => {
269+
test('adds jitter on top of a server hint to decorrelate workers', () => {
270+
// Math.random -> 0.5 gives jitter = floor(0.5 * 150) = 75ms, added on top of
271+
// the honored 5000ms hint so concurrent workers don't wake in lockstep.
272+
vi.spyOn(Math, 'random').mockReturnValue(0.5)
273+
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '5' }), 0)).toBe(5075)
274+
})
275+
276+
test('falls back to additive backoff when no hint is present', () => {
267277
vi.spyOn(Math, 'random').mockReturnValue(0)
268278
expect(rateLimitDelayMs(fakeResponse({}), 0)).toBe(1000)
269-
expect(rateLimitDelayMs(fakeResponse({}), 2)).toBe(4000)
279+
expect(rateLimitDelayMs(fakeResponse({}), 2)).toBe(3000)
270280
})
271281

272282
test('clamps any delay to the maximum', () => {
273283
vi.spyOn(Math, 'random').mockReturnValue(0)
274-
// 1000 * 2^10 would be ~1,000,000ms; clamped to 30,000.
275-
expect(rateLimitDelayMs(fakeResponse({}), 10)).toBe(30_000)
284+
// 1000 * (40 + 1) would be 41,000ms; clamped to 30,000.
285+
expect(rateLimitDelayMs(fakeResponse({}), 40)).toBe(30_000)
276286
// A far-future server hint is likewise capped.
277287
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '99999' }), 0)).toBe(30_000)
278288
})
279289

280-
test('never returns a negative delay for a stale hint', () => {
281-
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '-5' }), 0)).toBe(0)
282-
expect(rateLimitDelayMs(fakeResponse({ 'fastly-ratelimit-reset': '1' }), 0)).toBe(0)
290+
test('floors a stale or zero hint at the backoff instead of retrying instantly', () => {
291+
vi.spyOn(Math, 'random').mockReturnValue(0)
292+
// A negative Retry-After and an already-elapsed reset both compute to <= 0,
293+
// but must not collapse the retry to 0ms; they floor at the backoff.
294+
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '-5' }), 0)).toBe(1000)
295+
expect(rateLimitDelayMs(fakeResponse({ 'fastly-ratelimit-reset': '1' }), 0)).toBe(1000)
296+
// The floor grows with the attempt count, same as a hintless backoff.
297+
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '0' }), 2)).toBe(3000)
283298
})
284299
})

0 commit comments

Comments
 (0)