Skip to content

Error Handling and Network Failures

Network code fails in more ways than synchronous code. When you call fetch, at least four distinct things can go wrong, and each requires different handling:

  1. Network failure — no internet, DNS failure, CORS block → fetch rejects
  2. HTTP error — server responds but with a 4xx or 5xx status → response.ok is false
  3. Malformed JSON — response body is not valid JSON → response.json() rejects
  4. API-level error — server responds with 200 but includes an error in the body → need to check data.result

A robust fetch function handles all four.

function fetchRates(baseCurrency = 'USD') {
return fetch(`https://open.er-api.com/v6/latest/${baseCurrency}`)
.then(response => {
if (!response.ok) {
throw new Error(`Exchange rate API returned ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.result !== 'success') {
throw new Error(`API error: ${data['error-type'] ?? 'unknown'}`);
}
return data.rates;
});
}

The function returns a Promise. Callers add .catch to handle errors in context:

fetchRates()
.then(rates => updateCurrencyDisplay(rates))
.catch(error => showErrorBanner(`Could not load exchange rates: ${error.message}`));

Log technical error details for debugging, but show users something actionable:

.catch(error => {
console.error('fetchRates failed:', error); // for debugging
showErrorBanner('Exchange rates unavailable. Showing amounts in USD only.');
})

Never show raw error messages to users — they are usually meaningless to them and can expose implementation details.

For transient network errors, a simple retry is often appropriate:

function fetchWithRetry(url, retries = 2) {
return fetch(url).catch(error => {
if (retries > 0) {
console.warn(`Retrying (${retries} left)...`);
return fetchWithRetry(url, retries - 1);
}
throw error;
});
}

The .catch returns a new Promise — if there are retries left, it starts a new request. If retries are exhausted, it re-throws and the outer .catch handles it.

fetch has no built-in timeout. You can implement one with Promise.race:

function fetchWithTimeout(url, ms = 5000) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), ms)
);
return Promise.race([fetch(url), timeout]);
}

Promise.race settles as soon as the first Promise in the array settles. If the timeout fires first, the fetch is abandoned (though the underlying network request may still be in flight).

When exchange rates are unavailable, BudgetBuddy should still function — just showing amounts in USD rather than crashing:

let rates = { USD: 1 }; // safe default
fetchRates()
.then(newRates => {
rates = newRates;
renderExpenses(expenses, rates);
})
.catch(() => {
renderExpenses(expenses, rates); // render with USD-only default
});

The app degrades gracefully. The user sees their expenses even if the exchange rate call fails.

  1. Write fetchRates() as shown above, handling all four error cases.
  2. Test the HTTP error path by requesting https://open.er-api.com/v6/latest/INVALID_CURRENCY — confirm data.result is 'error'.
  3. Simulate a network failure by going offline in DevTools — confirm the .catch handles it.
  4. Implement fetchWithRetry(url, retries) and test it with a URL that consistently fails.
  5. Add a graceful degradation pattern so BudgetBuddy renders with { USD: 1 } rates if fetchRates() fails.
  • Four distinct failure modes: network failure, HTTP error, malformed JSON, API-level error — each needs explicit handling.
  • fetch only rejects on network failure; check response.ok for HTTP errors.
  • Show users actionable messages; log technical details separately.
  • Retry transient failures with recursion in .catch.
  • Promise.race implements timeouts — the first Promise to settle wins.
  • Design for graceful degradation — the app should work even when optional features (like currency conversion) fail.