fix(avatar): retry blob fetch with backoff to handle CDN propagation#34
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The onUploadCompleted callback fetches the uploaded blob via its CDN URL, but in production the CDN may not have propagated the file yet, causing a 404 error. This adds a fetchWithRetry utility that retries with exponential backoff (up to 3 retries with 1s/2s/4s delays) to handle CDN propagation delays. Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses intermittent production 404s during avatar processing by adding a retrying fetch helper with exponential backoff, and using it when re-fetching the newly uploaded blob for WebP conversion.
Changes:
- Added
fetchWithRetryutility to retryfetch()with exponential backoff. - Added unit tests for
fetchWithRetryusingbun:test. - Updated avatar upload
onUploadCompletedto usefetchWithRetry(blob.url)instead of a singlefetch(blob.url).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| lib/utils/fetch-with-retry.ts | Introduces a reusable fetch-with-retries helper used to mitigate CDN propagation delays. |
| lib/utils/fetch-with-retry.test.ts | Adds unit tests covering success, retry, and exhaustion paths. |
| app/api/avatar/upload/route.ts | Switches blob download to the retrying fetch helper in onUploadCompleted. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export async function fetchWithRetry( | ||
| url: string, | ||
| maxRetries: number = 3, | ||
| baseDelayMs: number = 1000 | ||
| ): Promise<Response> { | ||
| let lastResponse: Response = await fetch(url); | ||
|
|
||
| for (let attempt = 1; attempt <= maxRetries; attempt++) { | ||
| if (lastResponse.ok) { | ||
| return lastResponse; | ||
| } | ||
|
|
||
| const delay = baseDelayMs * Math.pow(2, attempt - 1); | ||
| await new Promise((resolve) => setTimeout(resolve, delay)); | ||
|
|
||
| lastResponse = await fetch(url); | ||
| } | ||
|
|
||
| return lastResponse; |
There was a problem hiding this comment.
fetchWithRetry retries on any non-2xx response and does not retry when fetch() throws (e.g., transient network/DNS errors). Since this helper is intended for CDN propagation (typically 404) it should either accept a retry predicate or restrict retries to expected retriable cases (e.g., 404/5xx), and treat thrown fetch errors as retryable too.
| export async function fetchWithRetry( | |
| url: string, | |
| maxRetries: number = 3, | |
| baseDelayMs: number = 1000 | |
| ): Promise<Response> { | |
| let lastResponse: Response = await fetch(url); | |
| for (let attempt = 1; attempt <= maxRetries; attempt++) { | |
| if (lastResponse.ok) { | |
| return lastResponse; | |
| } | |
| const delay = baseDelayMs * Math.pow(2, attempt - 1); | |
| await new Promise((resolve) => setTimeout(resolve, delay)); | |
| lastResponse = await fetch(url); | |
| } | |
| return lastResponse; | |
| /** | |
| * Decide whether a given HTTP status code should be retried. | |
| * For CDN propagation we primarily expect 404s until the asset is available, | |
| * and 5xx responses may also be transient. | |
| */ | |
| function shouldRetryStatus(status: number): boolean { | |
| return status === 404 || status >= 500; | |
| } | |
| export async function fetchWithRetry( | |
| url: string, | |
| maxRetries: number = 3, | |
| baseDelayMs: number = 1000 | |
| ): Promise<Response> { | |
| let lastResponse: Response | null = null; | |
| let lastError: unknown | null = null; | |
| const attemptFetch = async () => { | |
| try { | |
| lastResponse = await fetch(url); | |
| lastError = null; | |
| } catch (err) { | |
| lastResponse = null; | |
| lastError = err; | |
| } | |
| }; | |
| // Initial attempt before applying any delay. | |
| await attemptFetch(); | |
| for (let attempt = 1; attempt <= maxRetries; attempt++) { | |
| if (lastResponse) { | |
| if (lastResponse.ok) { | |
| return lastResponse; | |
| } | |
| // If the status is not considered retriable, stop retrying. | |
| if (!shouldRetryStatus(lastResponse.status)) { | |
| return lastResponse; | |
| } | |
| } else if (lastError && attempt > maxRetries) { | |
| // No response and we've exhausted retries: rethrow the last error. | |
| throw lastError; | |
| } | |
| const delay = baseDelayMs * Math.pow(2, attempt - 1); | |
| await new Promise((resolve) => setTimeout(resolve, delay)); | |
| await attemptFetch(); | |
| } | |
| // After all retries, return the last response if we have one, | |
| // otherwise rethrow the last encountered error. | |
| if (lastResponse) { | |
| return lastResponse; | |
| } | |
| if (lastError) { | |
| throw lastError; | |
| } | |
| // Fallback: this should be unreachable, but keep type-safety. | |
| throw new Error("fetchWithRetry failed without a response or error"); |
| const delay = baseDelayMs * Math.pow(2, attempt - 1); | ||
| await new Promise((resolve) => setTimeout(resolve, delay)); | ||
|
|
||
| lastResponse = await fetch(url); | ||
| } |
There was a problem hiding this comment.
On failed attempts, the previous Response body is discarded without being consumed/cancelled before the next fetch(). In Node/undici this can keep connections open and lead to resource/socket exhaustion under load. Cancel or drain lastResponse.body before sleeping/retrying (ideally in a try/finally).
onUploadCompletedfetches the uploaded blob via its CDN URL to convert it to WebP, but in production the CDN hasn't propagated the file yet, returning a 404. Preview deployments don't exhibit this due to different CDN caching behavior.lib/utils/fetch-with-retry.ts: Retries fetch with exponential backoff (1s → 2s → 4s, 3 retries max)app/api/avatar/upload/route.ts:fetch(blob.url)→fetchWithRetry(blob.url)✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.