Skip to content

fix(avatar): retry blob fetch with backoff to handle CDN propagation#34

Merged
sirily11 merged 3 commits into
mainfrom
copilot/fix-image-upload-issue
Mar 11, 2026
Merged

fix(avatar): retry blob fetch with backoff to handle CDN propagation#34
sirily11 merged 3 commits into
mainfrom
copilot/fix-image-upload-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 8, 2026

onUploadCompleted fetches 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.

  • New 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)
// Before: immediate fetch fails if CDN hasn't propagated
const response = await fetch(blob.url);

// After: retries with exponential backoff
const response = await 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.

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rxlab-auth Ready Ready Preview, Comment Mar 8, 2026 0:13am

Request Review

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>
@autopilot-project-manager autopilot-project-manager Bot added the enhancement New feature or request label Mar 8, 2026
Copilot AI changed the title [WIP] Fix image upload error in production environment Fix avatar upload 404 in production by retrying blob fetch with backoff Mar 8, 2026
@sirily11 sirily11 marked this pull request as ready for review March 11, 2026 08:26
Copilot AI review requested due to automatic review settings March 11, 2026 08:26
@autopilot-project-manager autopilot-project-manager Bot changed the title Fix avatar upload 404 in production by retrying blob fetch with backoff fix(avatar): retry blob fetch with backoff to handle CDN propagation Mar 11, 2026
@sirily11 sirily11 merged commit cedd550 into main Mar 11, 2026
5 checks passed
@sirily11 sirily11 deleted the copilot/fix-image-upload-issue branch March 11, 2026 08:26
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 fetchWithRetry utility to retry fetch() with exponential backoff.
  • Added unit tests for fetchWithRetry using bun:test.
  • Updated avatar upload onUploadCompleted to use fetchWithRetry(blob.url) instead of a single fetch(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.

Comment on lines +6 to +24
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;
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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");

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +22
const delay = baseDelayMs * Math.pow(2, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));

lastResponse = await fetch(url);
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants