Skip to content

Commit 14f20d6

Browse files
heiskrCopilot
andauthored
Throttle post-deploy Fastly URL purges to avoid 429s (#62200)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 737839c commit 14f20d6

2 files changed

Lines changed: 207 additions & 13 deletions

File tree

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

Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,66 @@ const MAX_URLS = 1000
3737
// than paginate through a huge change set.
3838
const 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

43101
type 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
163221
async function hardPurgeUrl(url: string, fastlyToken: string): Promise<void> {
164222
const withoutScheme = url.replace(/^https?:\/\//, '')
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
}

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
getChangedContentFiles,
1515
contentFilesToEnglishUrls,
1616
hardPurgeUrls,
17+
rateLimitDelayMs,
1718
} = await import('../purge-fastly-changed-content')
1819

1920
afterEach(() => {
@@ -150,6 +151,22 @@ describe('contentFilesToEnglishUrls', () => {
150151
})
151152

152153
describe('hardPurgeUrls', () => {
154+
// A minimal stand-in for a fetch Response, with a case-insensitive headers.get.
155+
function fakeResponse(
156+
status: number,
157+
{ headers = {}, ok = false }: { headers?: Record<string, string>; ok?: boolean } = {},
158+
) {
159+
const lower: Record<string, string> = {}
160+
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v
161+
return {
162+
ok,
163+
status,
164+
statusText: 'rate limited',
165+
headers: { get: (name: string) => lower[name.toLowerCase()] ?? null },
166+
text: async () => '',
167+
}
168+
}
169+
153170
test('issues a hard URL purge per URL (no soft-purge header)', async () => {
154171
fetchWithRetry.mockResolvedValue({ ok: true })
155172
await hardPurgeUrls(
@@ -177,4 +194,91 @@ describe('hardPurgeUrls', () => {
177194
).rejects.toThrow(/1 of 2 URL purge\(s\) failed/)
178195
expect(fetchWithRetry).toHaveBeenCalledTimes(2)
179196
})
197+
198+
test('retries a 429, honoring the hint, then succeeds', async () => {
199+
fetchWithRetry
200+
.mockResolvedValueOnce(fakeResponse(429, { headers: { 'retry-after': '0' } }))
201+
.mockResolvedValueOnce(fakeResponse(200, { ok: true }))
202+
await hardPurgeUrls(['https://docs.github.com/en/foo'], 'tok', 1, 0)
203+
expect(fetchWithRetry).toHaveBeenCalledTimes(2)
204+
})
205+
206+
test('gives up after the retry budget and reports the URL as failed', async () => {
207+
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+
)
211+
// Initial attempt + 5 retries.
212+
expect(fetchWithRetry).toHaveBeenCalledTimes(6)
213+
})
214+
215+
test('stops starting purges once the time budget is exceeded', async () => {
216+
fetchWithRetry.mockResolvedValue(fakeResponse(200, { ok: true }))
217+
// Budget below zero: the deadline is already in the past on the first loop
218+
// check, so no purge is attempted and every URL is reported as skipped.
219+
await expect(
220+
hardPurgeUrls(
221+
['https://docs.github.com/en/foo', 'https://docs.github.com/en/bar'],
222+
'tok',
223+
1,
224+
0,
225+
-1,
226+
),
227+
).rejects.toThrow(/2 of 2 URL purge\(s\) failed/)
228+
expect(fetchWithRetry).not.toHaveBeenCalled()
229+
})
230+
})
231+
232+
describe('rateLimitDelayMs', () => {
233+
function fakeResponse(headers: Record<string, string>): Response {
234+
const lower: Record<string, string> = {}
235+
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v
236+
return {
237+
headers: { get: (name: string) => lower[name.toLowerCase()] ?? null },
238+
} as unknown as Response
239+
}
240+
241+
afterEach(() => {
242+
vi.useRealTimers()
243+
vi.restoreAllMocks()
244+
})
245+
246+
test('honors Retry-After given in seconds', () => {
247+
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '5' }), 0)).toBe(5000)
248+
})
249+
250+
test('honors Retry-After given as an HTTP date', () => {
251+
vi.useFakeTimers()
252+
const now = new Date('2026-01-01T00:00:00Z')
253+
vi.setSystemTime(now)
254+
const when = new Date(now.getTime() + 3000).toUTCString()
255+
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': when }), 0)).toBe(3000)
256+
})
257+
258+
test('honors Fastly-RateLimit-Reset as a Unix timestamp', () => {
259+
vi.useFakeTimers()
260+
const now = new Date('2026-01-01T00:00:00Z')
261+
vi.setSystemTime(now)
262+
const reset = String(Math.floor(now.getTime() / 1000) + 7)
263+
expect(rateLimitDelayMs(fakeResponse({ 'fastly-ratelimit-reset': reset }), 0)).toBe(7000)
264+
})
265+
266+
test('falls back to exponential backoff when no hint is present', () => {
267+
vi.spyOn(Math, 'random').mockReturnValue(0)
268+
expect(rateLimitDelayMs(fakeResponse({}), 0)).toBe(1000)
269+
expect(rateLimitDelayMs(fakeResponse({}), 2)).toBe(4000)
270+
})
271+
272+
test('clamps any delay to the maximum', () => {
273+
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)
276+
// A far-future server hint is likewise capped.
277+
expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '99999' }), 0)).toBe(30_000)
278+
})
279+
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)
283+
})
180284
})

0 commit comments

Comments
 (0)