LESSON 146 · DEEPER PYTHON30 min

Build an Offline Python API Client Simulator

Build a deterministic offline client that retries temporary failures, validates user JSON, and caches only successful records.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Build an Offline Python API Client Simulator

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    Our final mini project for this sequence is an API client that never makes a real API call. We inject a fake transport that returns local response pairs, which means every demonstration is reproducible in the browser. The first fake response is 503 with a JSON error object; the next is 200 with a valid user object. What should the client cache? Certainly not the first error. A useful client needs to combine several skills: classify temporary failures, limit retries, check response status, parse JSON, validate fields, and cache only a trustworthy result.

  2. 00:40

    We will watch a broken cache-first design fail and then build a small class with an explicit contract. A reliable client separates transport from application policy. This project receives an injected transport function that accepts a path and returns a local (status, body) pair or raises TimeoutError. It never imports urllib, requests, or a network service. The client method get_user(path) retries temporary failures up to a finite limit, accepts a successful JSON user record only after validating id and name, and caches that record by path. Because the transport is fake, every failure sequence can be replayed in a

  3. 01:23

    browser exercise and compared with an expected call count. The broken client calls the transport once, stores the response immediately, decodes its body, and returns it. Because the first body is valid JSON, this function returns an error object as though it were a user. The second call to the same path hits the cache, so the fake transport's healthy 200 response is never even seen. The call count stays at one. This is a realistic failure mode: a cache can turn a short temporary outage into a persistent incorrect answer. The problem is not whether JSON parsing succeeded. The problem is where the cache insertion occurred.

  4. 02:06

    Before any value enters the cache, we need to decide whether it meets the client's user-record contract. The broken client stores the first response in its cache before checking whether it was successful. A fake server returns 503 with a JSON error body on its first call and a valid 200 user body on its second. The broken method decodes and returns the error as if it were a user. A second request never reaches the transport because the cached error is reused. Print both status and call count; the output reveals that caching happened at the wrong boundary. Caching is not automatically helpful.

  5. 02:46

    It amplifies mistakes when invalid or transient responses are stored as permanent data. The diagnosis prints statuses and decoded keys for both scripted responses. The 503 body has an error key, while the 200 body has id and name. Even if both are syntactically valid JSON, only one is eligible to become a user record. We also make a deliberate distinction between retryable and permanent responses. This simplified client retries TimeoutError and statuses from 500 upward, because they may be transient. It reports a 404 immediately rather than waiting for repeated identical failures.

  6. 03:29

    Once a 200 arrives, the code must still validate that the decoded value is a dictionary with an actual integer id and nonblank string name. Only after all of that can the cache represent trusted data. The corrected client checks its cache first for a previously validated record. Otherwise it calls the transport. TimeoutError and status codes at least 500 are transient for this simplified policy and can be retried until the maximum is reached. A 404 or other non-200 status below 500 is reported immediately as ValueError. A 200 response is decoded with json.loads and checked as an object with an exact

  7. 04:11

    integer id and nonblank string name. Only that validated record enters the cache. The method then returns a copy so callers cannot mutate the cached dictionary by accident. The corrected class accepts the fake transport in its constructor and requires a positive integer retry limit. get_user checks for a prior validated record, otherwise loops a bounded number of times. A timeout triggers another attempt unless it was the final one. A server error does the same, then raises if it never recovers. Other non-200 statuses fail immediately. A successful status goes through JSON parsing and schema validation.

  8. 04:55

    The code makes a small dictionary with only id and stripped name, stores it, and returns a copy. The fixed run shows the 503 followed by the 200 and prints Ada's validated record. No external site or secret is involved. Look at the order of operations in get_user. A cache hit can only contain a value produced by the successful branch, because no other branch writes to cache. TimeoutError is caught only around the transport call; a bad JSON payload is not mislabeled as a timeout. Server status is checked before body decoding. A 404 fails immediately according to this lesson's simplified policy.

  9. 05:38

    The 200 payload is parsed and checked, including exact integer id type and a nonblank name. The new record is flat, so dict(record) is a sufficient copy at both insertion and return boundaries. If nested mutable values were added later, the copy policy would have to be reconsidered. This ordering makes retry, validation, and caching understandable as separate decisions. The verification run calls the same path twice. After the first successful lookup, it mutates the returned name to changed. The next lookup still returns Ada, and the transport call count remains one in that separate test.

  10. 06:18

    That shows both cache reuse and protection against caller mutation. The task checker also verifies a 404 is not retried, a malformed 200 body is rejected, and a persistent timeout stops at the configured limit. A full production client would need a policy for 429 rate limiting, authentication, content types, cache expiry, backoff with jitter, and cancellation. This small project focuses on the boundaries we can prove with local tests. Try modifying the fake response list and predict the exact number of transport calls before running it. The verification run shows a 503 followed by 200, a repeated lookup using the cache,

  11. 07:01

    and a timeout that reaches the limit. A malformed JSON 200 is not retried under this policy because it is an invalid response rather than a known temporary transport failure. Real clients have richer concerns: authentication, content type, 429 rate limits, backoff with jitter, cancellation, cache lifetime, and privacy. Those require explicit product choices. The small offline version demonstrates how to combine status handling, schema validation, retry bounds, and caching without hiding assumptions behind an external API.

Understand the concept

A reliable client separates transport from application policy. This project receives an injected transport function that accepts a path and returns a local (status, body) pair or raises TimeoutError. It never imports urllib, requests, or a network service. The client method get_user(path) retries temporary failures up to a finite limit, accepts a successful JSON user record only after validating id and name, and caches that record by path. Because the transport is fake, every failure sequence can be replayed in a browser exercise and compared with an expected call count.

The broken client stores the first response in its cache before checking whether it was successful. A fake server returns 503 with a JSON error body on its first call and a valid 200 user body on its second. The broken method decodes and returns the error as if it were a user. A second request never reaches the transport because the cached error is reused. Print both status and call count; the output reveals that caching happened at the wrong boundary. Caching is not automatically helpful. It amplifies mistakes when invalid or transient responses are stored as permanent data.

The corrected client checks its cache first for a previously validated record. Otherwise it calls the transport. TimeoutError and status codes at least 500 are transient for this simplified policy and can be retried until the maximum is reached. A 404 or other non-200 status below 500 is reported immediately as ValueError. A 200 response is decoded with json.loads and checked as an object with an exact integer id and nonblank string name. Only that validated record enters the cache. The method then returns a copy so callers cannot mutate the cached dictionary by accident.

The verification run shows a 503 followed by 200, a repeated lookup using the cache, and a timeout that reaches the limit. A malformed JSON 200 is not retried under this policy because it is an invalid response rather than a known temporary transport failure. Real clients have richer concerns: authentication, content type, 429 rate limits, backoff with jitter, cancellation, cache lifetime, and privacy. Those require explicit product choices. The small offline version demonstrates how to combine status handling, schema validation, retry bounds, and caching without hiding assumptions behind an external API.

  • Inject a fake transport
  • Retry only a stated transient set
  • Validate before caching
  • Return a copy of cached data

See it step by step

Read the code, predict the output, then compare it with the result.

01. Replay a fake response sequence

PYTHON
responses = [(503,'{"error":"busy"}'),(200,'{"id":7,"name":"Ada"}')]
def fake(path): return responses.pop(0)
print(fake('/users/7')[0], fake('/users/7')[0])
EXPECTED OUTPUT
503 200

The local transport makes a temporary error followed by success reproducible.

02. Do not cache an error

PYTHON
cache = {}
status, body = 503, '{"error":"busy"}'
if status == 200: cache['/users/7'] = body
print(cache)
EXPECTED OUTPUT
{}

A transient error leaves the cache empty, allowing another transport call.

03. Return an independent record

PYTHON
cache = {'/users/7': {'id':7,'name':'Ada'}}
returned = dict(cache['/users/7'])
returned['name'] = 'changed'
print(cache['/users/7'])
EXPECTED OUTPUT
{'id': 7, 'name': 'Ada'}

Mutating a returned flat dictionary does not alter the stored validated record.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Simulate transport deterministically

A reliable client separates transport from application policy. This project receives an injected transport function that accepts a path and returns a local (status, body) pair or raises TimeoutError. It never imports urllib, requests, or a network service. The client method get_user(path) retries temporary failures up to a finite limit, accepts a successful JSON user record only after validating id and name, and caches that record by path. Because the transport is fake, every failure sequence can be replayed in a browser exercise and compared with an expected call count.

A fake transport can be a function that pops the next response from a local list. It has the same call shape as a real adapter but no network dependency. Count calls so retries are observable rather than assumed.

PYTHON
responses = [(503,'{"error":"busy"}'),(200,'{"id":7,"name":"Ada"}')]
def fake(path): return responses.pop(0)
print(fake('/users/7')[0], fake('/users/7')[0])
EXPECTED OUTPUT
503 200
Follow the reasoning
  1. Build a two-response fake transport.
  2. Count calls made by the client.
02 / 03

Set the cache boundary

The broken client stores the first response in its cache before checking whether it was successful. A fake server returns 503 with a JSON error body on its first call and a valid 200 user body on its second. The broken method decodes and returns the error as if it were a user. A second request never reaches the transport because the cached error is reused. Print both status and call count; the output reveals that caching happened at the wrong boundary. Caching is not automatically helpful. It amplifies mistakes when invalid or transient responses are stored as permanent data.

A 503 response may contain perfectly valid JSON, but it is still an error response. Do not store it as a user record. First apply status policy, then decode and validate the 200 body, and only then insert it into the cache.

PYTHON
cache = {}
status, body = 503, '{"error":"busy"}'
if status == 200: cache['/users/7'] = body
print(cache)
EXPECTED OUTPUT
{}
Follow the reasoning
  1. Leave cache empty after 503.
  2. Insert only after validating a 200 record.
03 / 03

Protect cached values

The corrected client checks its cache first for a previously validated record. Otherwise it calls the transport. TimeoutError and status codes at least 500 are transient for this simplified policy and can be retried until the maximum is reached. A 404 or other non-200 status below 500 is reported immediately as ValueError. A 200 response is decoded with json.loads and checked as an object with an exact integer id and nonblank string name. Only that validated record enters the cache. The method then returns a copy so callers cannot mutate the cached dictionary by accident.

A cached dictionary is mutable. This project's record contains only scalar id and name fields, so dict(record) makes a sufficient shallow copy. If a future schema contains nested lists or dictionaries, revisit copy strategy instead of assuming a shallow copy protects every level.

PYTHON
cache = {'/users/7': {'id':7,'name':'Ada'}}
returned = dict(cache['/users/7'])
returned['name'] = 'changed'
print(cache['/users/7'])
EXPECTED OUTPUT
{'id': 7, 'name': 'Ada'}
Follow the reasoning
  1. Edit a returned dict.
  2. Verify the cache remains unchanged.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnImplement OfflineClient(transport, max_attempts=3). get_user(path) retries TimeoutError and status >=500, rejects other non-200 statuses or invalid user JSON, caches only a validated {'id': int, 'name': nonblank str} record, and returns a copy. Reject max_attempts unless it is a positive integer.
Need a hint?

Check cache first. Call transport in a bounded loop. Retry only timeout or 5xx, validate 200 JSON and schema, then store a copy in cache and return another copy.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.When should a response enter this cache?

    02.What happens to a 404 in this simplified policy?

    03.Why return a copy of the cached record?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Caching an error response

    COMMON MISTAKE
    self.cache[path] = self.transport(path) before status validation
    FIX
    cache only a validated 200 user record

    What happens: A temporary 503 becomes a permanent cached error.
    Cache insertion belongs after transport and schema checks.

    Returning the mutable cached object

    COMMON MISTAKE
    return self.cache[path]
    FIX
    return dict(self.cache[path])

    What happens: A caller changes the cached name for every later lookup.
    A shallow copy is enough for this flat id/name record.

    Browse this lesson’s problem fixes

    Sources and further reading