Thuta Learning
ExercisesWeb Developmentintermediate

Exercise: Rate Limit and Error Handling

What you'll walk away with

  • Explain the core ideas behind Exercise: Rate Limit and Error Handling
  • Read the diagram and trace how a request, response, or event flows through the system
  • Explain how this applies to a real API integration you might build

Build the mental model

A real integration doesn't just call an API and hope for 200 OK every time. It has to survive the messy middle: a request that gets rate limited, a token that expired, a server having a bad minute.

This exercise combines two skills from this course — retry-with-backoff and clear error communication — into one small, reusable piece of code: an API call wrapper.

The wrapper's job is to sit between your application and the raw API call, translating whatever comes back into a successful result or a message a non-technical user could actually understand. "Error: 429" means nothing to an end user; "We're a bit busy, trying again..." does.

StatusShould the wrapper retry?
429 / 500Yes — often temporary; wait and retry with backoff.
401 / 403No — the request itself needs fixing, not repeating.
404No — the resource isn't there; retrying won't create it.

Always bound your retries

An unbounded retry loop against a service that's genuinely down can hang your application indefinitely and hammer a service that's already struggling. A maximum retry count protects both sides.

text
REQUEST HANDLING STATE MACHINE
------------------------------
LOADING
  |
  v
call mockApiCall()  -->  returns one status code
  |
  |-- 200 --> SUCCESS         (show the data, done)
  |-- 401 --> NEEDS AUTH      (ask user to sign in again, done)
  |-- 403 --> FORBIDDEN       (explain no access, done)
  |-- 404 --> NOT FOUND       (explain missing item, done)
  |
  |-- 429 --> retries left?
  |             |-- yes --> wait (backoff) --> back to LOADING
  |             |-- no  --> GIVE UP (ask user to retry later)
  |
  |-- 500 --> retries left?
                |-- yes --> wait (backoff) --> back to LOADING
                |-- no  --> GIVE UP (ask user to retry later)

Connect it to a real scenario

You'll build callWithHandling(mockApiCall) — a function that calls a pre-scripted mock API and returns a friendly outcome for every status it might see.

Build a deterministic mock

createMockApiCall(statusSequence) returns a function that, on each call, returns the next status code from a fixed array — no flaky network involved, so the outcome is exactly reproducible.

Handle terminal states immediately

On 200, return success right away. On 401, 403, or 404, return a specific friendly message immediately without retrying, since another attempt cannot change these outcomes.

Retry 429 and 500 with backoff

If attempts remain, wait for a delay that doubles each retry (exponential backoff), then loop again. This gives a struggling service room to recover instead of hammering it immediately.

Give up cleanly when the budget runs out

Once maxRetries is exceeded, return a clear "try again later" message rather than looping forever or throwing an unhandled error.

Run it against three scenarios — rate-limited-then-success, an auth failure, and persistent server errors — and the exact same function produces three different, correct outcomes.

Try the working example

javascript
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// Builds a deterministic mock API call: each call returns the next
// status code in a fixed, pre-scripted sequence.
function createMockApiCall(statusSequence) {
  let index = 0;
  return function mockApiCall() {
    const status = statusSequence[Math.min(index, statusSequence.length - 1)];
    index++;
    return { status };
  };
}

async function callWithHandling(mockApiCall, options = {}) {
  const maxRetries = options.maxRetries ?? 3;
  const initialDelayMs = options.initialDelayMs ?? 50;
  const log = [];
  let delay = initialDelayMs;

  for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
    const response = mockApiCall();
    log.push(`Attempt ${attempt}: received ${response.status}`);

    if (response.status === 200) {
      log.push("Result: success.");
      return { ok: true, message: "Loaded successfully.", attempts: attempt, log };
    }

    if (response.status === 401) {
      log.push("Result: stopped (not retryable).");
      return { ok: false, message: "Please sign in again.", attempts: attempt, log };
    }

    if (response.status === 403) {
      log.push("Result: stopped (not retryable).");
      return { ok: false, message: "You don't have access to this.", attempts: attempt, log };
    }

    if (response.status === 404) {
      log.push("Result: stopped (not retryable).");
      return { ok: false, message: "That item couldn't be found.", attempts: attempt, log };
    }

    if (response.status === 429 || response.status === 500) {
      const label = response.status === 429 ? "rate limited" : "server error";
      if (attempt > maxRetries) {
        log.push("Result: gave up after max retries.");
        const msg =
          response.status === 429
            ? "Still busy after several tries. Please try again shortly."
            : "The service is having trouble. Please try again shortly.";
        return { ok: false, message: msg, attempts: attempt, log };
      }
      log.push(`Result: ${label}, backing off ${delay}ms then retrying.`);
      await sleep(delay);
      delay *= 2; // exponential backoff
      continue;
    }

    log.push("Result: stopped (unrecognized status).");
    return { ok: false, message: "Something unexpected happened.", attempts: attempt, log };
  }
}

async function main() {
  console.log("--- Scenario A: rate limited twice, then succeeds ---");
  const callA = createMockApiCall([429, 429, 200]);
  const resultA = await callWithHandling(callA, { maxRetries: 3, initialDelayMs: 20 });
  console.log(resultA.log.join("\n"));
  console.log(`Final: ok=${resultA.ok} message="${resultA.message}" attempts=${resultA.attempts}`);

  console.log("\n--- Scenario B: needs auth ---");
  const callB = createMockApiCall([401]);
  const resultB = await callWithHandling(callB, { maxRetries: 3, initialDelayMs: 20 });
  console.log(resultB.log.join("\n"));
  console.log(`Final: ok=${resultB.ok} message="${resultB.message}" attempts=${resultB.attempts}`);

  console.log("\n--- Scenario C: server errors exceed retry budget ---");
  const callC = createMockApiCall([500, 500, 500, 500]);
  const resultC = await callWithHandling(callC, { maxRetries: 2, initialDelayMs: 20 });
  console.log(resultC.log.join("\n"));
  console.log(`Final: ok=${resultC.ok} message="${resultC.message}" attempts=${resultC.attempts}`);
}

main();
You should see
--- Scenario A: rate limited twice, then succeeds ---
Attempt 1: received 429
Result: rate limited, backing off 20ms then retrying.
Attempt 2: received 429
Result: rate limited, backing off 40ms then retrying.
Attempt 3: received 200
Result: success.
Final: ok=true message="Loaded successfully." attempts=3

--- Scenario B: needs auth ---
Attempt 1: received 401
Result: stopped (not retryable).
Final: ok=false message="Please sign in again." attempts=1

--- Scenario C: server errors exceed retry budget ---
Attempt 1: received 500
Result: server error, backing off 20ms then retrying.
Attempt 2: received 500
Result: server error, backing off 40ms then retrying.
Attempt 3: received 500
Result: gave up after max retries.
Final: ok=false message="The service is having trouble. Please try again shortly." attempts=3

5-minute try-it

Extend callWithHandling so it also logs a clear, distinct message for a 403 ("forbidden") separate from a 401 ("needs auth") — the two are often confused but call for different user-facing guidance. Then create a fourth scenario using createMockApiCall([500, 429, 500, 200]) with maxRetries: 3, run it, and predict the attempt count and final message before you check the actual output.

One important caution

Retrying every failure the same way, including 401/403/404 — this wastes time and rate-limit quota on errors that retrying can never fix.

Using a fixed retry delay instead of backoff — hammering a struggling service at a constant interval makes recovery harder, not easier.

MDN — Using the Fetch API (error handling patterns)API Integration & Webhooks

API Integration Glossary — Common Terms

TermMeaning
TimeoutA limit on how long a client waits for a response before giving up on the request.
RetrySending the same request again after a failure, usually only for failures likely to be temporary.
BackoffWaiting progressively longer between each retry attempt instead of retrying immediately.
JitterA small random amount added to a backoff delay so many clients retrying at once don't all collide at the exact same moment.
IdempotencyThe property of an operation producing the same end result no matter how many times it's safely repeated.
Idempotency KeyA unique value sent with a request so the server can recognize and safely ignore an accidental duplicate.
Secret ManagementThe practice of storing and handling API keys, tokens, and passwords so they never end up exposed in code or logs.
Environment VariableA value set outside the codebase (like a secret or a config setting) that the running program reads at startup.
Response ValidationChecking that a response actually has the fields and shape your code expects before using it.
SDKA software development kit — a pre-built library from a provider that wraps their raw API in easier, ready-made functions.
WebhookA URL you register with a provider so it can push you an HTTP request the moment an event happens, instead of you polling for it.
Webhook PayloadThe JSON body a webhook request carries, describing what event occurred and its details.
Webhook SignatureA cryptographic value included in a webhook request that lets you verify it genuinely came from the provider.
HMACHash-based Message Authentication Code — the algorithm commonly used with a shared secret to generate and check webhook signatures.
Replay AttackResending a previously valid, captured request to trick a system into processing it again as if it were new.
Webhook RetryA provider's own automatic re-sending of a webhook if your endpoint didn't respond successfully the first time.
PollingRepeatedly asking an API "has anything changed?" on a schedule, as an alternative to receiving webhooks.
Rate Limit HeadersResponse headers (like X-RateLimit-Remaining) that tell you how much of your request quota is left before you hit a 429.
Postman EnvironmentA named set of variables (like base URL or token) in Postman that you can switch between, e.g. dev vs. production.
Postman CollectionA saved, organized set of requests in Postman that can be shared, reused, or run together as a suite.
Test CaseA specific scenario with an expected outcome, used to check that an integration behaves correctly under that condition.
Automated API TestingRunning test cases against an API automatically (e.g. via a Postman collection or a script) instead of checking manually every time.
API DocumentationA provider's written reference describing how to call their API — endpoints, auth, parameters, responses, and errors.
API Documentation ChecklistThe fixed set of facts (base URL, auth, endpoint, params, headers, body, response, errors, rate limit) to locate in any new API's docs.
Third-Party APIAn API owned and operated by a different company than the one integrating with it.
Backend ProxyYour own server sitting between the frontend and a third-party API, so secrets stay server-side and never reach the browser.
CredentialAny secret value (API key, token, password) used to prove identity or authorization to a system.
CacheA temporary local copy of a response kept so repeated requests for the same data don't need to hit the API again.
MonitoringOngoing tracking of an integration's health (error rates, latency, failures) so problems are caught before users report them.
API VersionA label (like v1, v2) identifying which revision of an API's behavior and shape you're calling.
DeprecationA formal notice that an API version or feature will stop working at a future date, so integrations need to migrate ahead of it.
Bearer Token (refresher)A credential sent in the Authorization header as "Bearer <token>" — whoever holds it is treated as authenticated.
API Key (refresher)A simple, static credential identifying and authorizing the calling application, usually sent as a header or query parameter.
Authentication vs Authorization (refresher)Authentication proves who is calling; authorization decides what that caller is allowed to do.

API Integration & Troubleshooting Checklist

SituationWhat to do
A request keeps timing outConfirm a sane timeout is set (don't wait forever), then apply retry-with-backoff rather than retrying instantly in a tight loop.
You got a 429 Too Many RequestsBack off and retry with an increasing delay, honor any Retry-After or rate-limit headers the response provides, and cap the retry count.
A request seems to have succeeded twiceUse an idempotency key on write operations so a genuine duplicate (from a retry or double click) is safely ignored by the server instead of applied twice.
A webhook endpoint is receiving requests you're not sure are genuineVerify the webhook signature (HMAC) against your shared secret before trusting or processing the payload at all.
The same webhook event arrived twiceTreat this as expected provider behavior, not a bug — store processed event IDs and skip any you've already handled.
A third-party response is missing an expected fieldAdd response validation before using the data, and fail with a clear internal error instead of letting undefined silently flow through your app.
You're not sure whether to use the SDK or raw APIPrefer the official SDK when one exists for your language — it already handles auth headers, retries, and pagination correctly; fall back to raw calls only for gaps it doesn't cover.
You need to test more than the happy pathWrite test cases for each realistic status (401, 403, 404, 429, 500) using a mock, not just the 200 case — automate them so they run on every change.
You're integrating an unfamiliar API for the first timeRun the documentation checklist first (base URL, auth, endpoint, params, headers, body, response, errors, rate limit) before writing any integration code.
A secret credential might have leakedRotate (revoke and reissue) the credential immediately through the provider's dashboard — don't just remove it from code, since the old value is already compromised.
Deciding whether an API call should happen from the frontend or backendIf the call needs a secret credential, route it through a backend proxy — never embed a secret key in frontend code, where anyone can read it.
An integration that worked yesterday broke todayCheck the provider's changelog for a version bump or deprecation notice before assuming your own code is at fault.

Easy traps

  • Retrying every failure the same way, including 401/403/404 — this wastes time and rate-limit quota on errors that retrying can never fix.
  • Using a fixed retry delay instead of backoff — hammering a struggling service at a constant interval makes recovery harder, not easier.
  • If you haven't taken the API Tutorial yet, it's worth finishing that first -- this course doesn't re-teach REST/HTTP/auth basics, it builds on top of them with webhooks, testing, reliability, and integration architecture.

Exercise

Extend callWithHandling so it also logs a clear, distinct message for a 403 ("forbidden") separate from a 401 ("needs auth") — the two are often confused but call for different user-facing guidance. Then create a fourth scenario using createMockApiCall([500, 429, 500, 200]) with maxRetries: 3, run it, and predict the attempt count and final message before you check the actual output.

You'll know it worked when: --- Scenario A: rate limited twice, then succeeds --- Attempt 1: received 429 Result: rate limited, backing off 20ms then retrying. Attempt 2: received 429 Result: rate limited, backing off 40ms then retrying. Attempt 3: received 200 Result: success. Final: ok=true message="Loaded successfully." attempts=3 --- Scenario B: needs auth --- Attempt 1: received 401 Result: stopped (not retryable). Final: ok=false message="Please sign in again." attempts=1 --- Scenario C: server errors exceed retry budget --- Attempt 1: received 500 Result: server error, backing off 20ms then retrying. Attempt 2: received 500 Result: server error, backing off 40ms then retrying. Attempt 3: received 500 Result: gave up after max retries. Final: ok=false message="The service is having trouble. Please try again shortly." attempts=3

Exercise: Rate Limit and Error Handling | Thuta Learning