← REST APIs: integrate applications and diagnose failures
09 / 12 · 60 MIN

Variants, validation, and cache boundaries

Diagnose incorrect representations, validate stored responses, and define freshness criteria without hiding failures.

Identify the representation that may be reused

Two responses with the same URL and 200 status can have different bodies. In an international portal, Accept-Language may select PT or EN; at another endpoint, identity may determine personalized data. Start diagnosis by recording the actual request, received representation, and response policy. Vary communicates request dimensions relevant to variant selection. It does not turn restricted content into public content. Key selection, permission to store, and access rights are distinct decisions that must form a coherent contract. Check each explicitly during acceptance.

Read validation without inventing a document

A client may store the body and ETag and send If-None-Match on its next read. A 304 confirms reuse of the corresponding representation; it does not supply a new empty JSON document. Consumer code must retain the body and update applicable metadata. If it requests EN with the PT validator, the origin evaluates the selected EN representation. The lab uses different ETags for different bodies and demonstrates a complete response when the variant changes. It supports one entity tag rather than the full list or wildcard grammar.

Separate storage from freshness

Unqualified no-cache permits a storage strategy with mandatory validation before reuse. No-store prohibits storing the response in applicable scopes and is not evidence that earlier copies disappeared. Unqualified private prevents shared storage while allowing a private cache to satisfy the remaining rules. When investigating an exposed report, correct future policy and address existing copies and observed impact. A header does not replace authentication, authorization, encryption, or incident handling. Do not use a short lifetime as justification for sharing personalized data. Document the affected cache locations.

Define behavior when the origin fails

Freshness budget depends on calculated age, not when someone reopened the screen. In the simplified example, a 120-second lifetime minus 95 seconds of age leaves 25 seconds. When a stale response requires must-revalidate, losing the origin connection does not allow silently presenting it as current. Discuss with the service owner whether a historical or degraded mode exists, which decisions it excludes, and how age is displayed. Do not change the guarantee without making the new requirement explicit and documenting its acceptance criteria.

Run the local laboratory

Save this lesson’s code as run.py and execute python3 run.py --output evidence.json in a temporary directory. The program opens HTTP only on 127.0.0.1 at a system-assigned port and closes the server afterward. Compare PT and EN, validate a response, change its revision, and observe the new body. Incorrect-key and storage-decision examples are in-memory models separate from HTTP transport. X-Lab-Tenant is synthetic context, not authentication. There is no TLS, actual CDN, complete HTTP cache, banking request, or distributed durability in this laboratory.

Accept observable results

During the workshop, predict outcomes before reading evidence. Explain why 304 does not replace the body, why ETags differ, and where the URL-only key fails. Repeat requests in both orders to avoid a demonstration that works only with an empty cache. To apply the design in a project, identify actual intermediaries and plan authorized checks with distinct variants and identities. Local evidence teaches the reasoning but does not automatically approve a product configuration or an institution’s policy. Keep fictional examples separate from operational procedures.

"""Original bounded HTTP teaching fixture, Python 3.13.1 reference runtime.
Run: python3 run.py --output evidence.json
Loopback and synthetic data only. Not a production server, complete HTTP cache,
authentication system, database snapshot engine or durable cursor store.
"""
import argparse
import copy
import hashlib
import http.client
import json
import platform
import threading
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlsplit


def encode(value):
 return json.dumps(value, sort_keys=True, separators=(',', ':')).encode


class State:
 def __init__(self):
 self.reset

 def reset(self):
 self.rows = [{'id': n, 'tenant': 'A', 'amount': n * 10, 'state': 'open'} for n in range(1, 7)]
 self.rows += [{'id': 101, 'tenant': 'B', 'amount': 700, 'state': 'open'}]
 self.tokens = {}
 self.clock = 100
 self.allowed = {'A', 'B'}
 self.revision = 1


def handler_for(state):
 class Handler(BaseHTTPRequestHandler):
 protocol_version = 'HTTP/1.1'

 def log_message(self, *args):
 pass

 def send(self, status, value=None, headers=None):
 body = b'' if value is None else encode(value)
 self.send_response(status)
 for key, val in (headers or {}).items:
 self.send_header(key, val)
 if status!= 304:
 self.send_header('Content-Type', 'application/json')
 self.send_header('Content-Length', str(len(body)))
 self.end_headers
 if body:
 self.wfile.write(body)

 def do_GET(self):
 parsed = urlsplit(self.path)
 query = parse_qs(parsed.query)
 if parsed.path in ('/catalogue', '/profile', '/secret'):
 # Exact pt/en labels only; not a general Accept-Language parser.
 lang = 'pt' if self.headers.get('Accept-Language') == 'pt' else 'en'
 tenant = self.headers.get('X-Lab-Tenant', '')
 if parsed.path!= '/catalogue' and tenant not in state.allowed:
 return self.send(403, {'error': 'synthetic-context-denied'})
 policy = {'/catalogue': 'public, no-cache', '/profile': 'private, max-age=60', '/secret': 'no-store'}[parsed.path]
 value = {'language': lang, 'label': 'Fundos' if lang == 'pt' else 'Funds', 'revision': state.revision}
 if parsed.path!= '/catalogue':
 value['tenant'] = tenant
 tag = '"' + hashlib.sha256(encode(value)).hexdigest + '"'
 headers = {'ETag': tag, 'Vary': 'Accept-Language', 'Cache-Control': policy, 'Content-Language': lang}
 # Single entity-tag comparison only, no lists or wildcard support.
 condition = self.headers.get('If-None-Match', '').removeprefix('W/')
 if condition == tag:
 return self.send(304, headers=headers)
 return self.send(200, value, headers)
 if parsed.path!= '/list':
 return self.send(404, {'error': 'not-found'})
 tenant = self.headers.get('X-Lab-Tenant', '')
 if tenant not in state.allowed:
 return self.send(403, {'error': 'synthetic-context-denied'})
 mode = query.get('mode', ['keyset'])[0]
 filter_ = query.get('filter', ['open'])[0]
 if mode not in ('offset', 'keyset', 'snapshot') or filter_ not in ('open', 'all'):
 return self.send(400, {'error': 'unsupported-query'})
 cursor = query.get('cursor', [''])[0]
 previous = state.tokens.get(cursor) if cursor else None
 if cursor and previous is None:
 return self.send(400, {'error': 'unknown-cursor'})
 if previous:
 if any(previous[k]!= v for k, v in [('tenant', tenant), ('mode', mode), ('filter', filter_)]):
 return self.send(400, {'error': 'cursor-scope-mismatch'})
 if state.clock >= previous['expires']:
 return self.send(410, {'error': 'cursor-expired'})
 live = sorted([copy.deepcopy(r) for r in state.rows if r['tenant'] == tenant and (filter_ == 'all' or r['state'] == filter_)], key=lambda r: r['id'])
 frozen = previous['snapshot'] if previous and mode == 'snapshot' else copy.deepcopy(live)
 if mode == 'keyset':
 candidates = [r for r in live if r['id'] > (previous['last'] if previous else 0)]
 index = 0
 else:
 candidates = frozen if mode == 'snapshot' else live
 index = previous['index'] if previous else 0
 page = candidates[index:index + 2]
 more = len(candidates) > index + len(page)
 next_ = ''
 if more:
 next_ = uuid.uuid4.hex
 state.tokens[next_] = {'tenant': tenant, 'mode': mode, 'filter': filter_, 'expires': state.clock + 10, 'index': index + len(page), 'last': page[-1]['id'], 'snapshot': frozen if mode == 'snapshot' else None}
 return self.send(200, {'items': page, 'next_page_token': next_}, {'Cache-Control': 'no-store'})
 return Handler


def run:
 state = State
 server = ThreadingHTTPServer(('127.0.0.1', 0), handler_for(state))
 thread = threading.Thread(target=server.serve_forever, daemon=True)
 thread.start
 checks = []

 def check(name, actual, expected):
 if actual!= expected:
 raise AssertionError((name, actual, expected))
 checks.append({'name': name, 'actual': actual, 'expected': expected, 'passed': True})

 def request(path, headers=None):
 client = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3)
 try:
 client.request('GET', path, headers=headers or {})
 response = client.getresponse
 body = response.read
 return response.status, {k.lower: v for k, v in response.getheaders}, body
 finally:
 client.close

 def listing(mode, cursor='', tenant='A', filter_='open'):
 status, _, body = request('/list?' + urlencode({'mode': mode, 'cursor': cursor, 'filter': filter_}), {'X-Lab-Tenant': tenant})
 return status, json.loads(body)

 def ids(data):
 return [r['id'] for r in data['items']]

 try:
 pt = request('/catalogue', {'Accept-Language': 'pt'})
 en = request('/catalogue', {'Accept-Language': 'en'})
 check('localized representations', [json.loads(pt[2])['label'], json.loads(en[2])['label']], ['Fundos', 'Funds'])
 check('vary declared', pt[1]['vary'], 'Accept-Language')
 check('variant validators differ', pt[1]['etag']!= en[1]['etag'], True)
 validated = request('/catalogue', {'Accept-Language': 'pt', 'If-None-Match': pt[1]['etag']})
 check('not modified response', [validated[0], len(validated[2])], [304, 0])
 validated_body = pt[2] if validated[0] == 304 else validated[2]
 check('cached body retained after validation', json.loads(validated_body)['label'], 'Fundos')
 weak = request('/catalogue', {'Accept-Language': 'pt', 'If-None-Match': 'W/' + pt[1]['etag']})
 check('weak comparison for GET', weak[0], 304)
 cross = request('/catalogue', {'Accept-Language': 'en', 'If-None-Match': pt[1]['etag']})
 check('other language needs representation', [cross[0], json.loads(cross[2])['language']], [200, 'en'])
 state.revision = 2
 changed = request('/catalogue', {'Accept-Language': 'pt', 'If-None-Match': pt[1]['etag']})
 check('changed representation returns body', [changed[0], json.loads(changed[2])['revision']], [200, 2])
 # Deliberately wrong and corrected in-memory cache keys; not an HTTP proxy.
 bad = {'/catalogue': pt[2]}
 good = {('/catalogue', 'pt'): pt[2], ('/catalogue', 'en'): en[2]}
 check('URL-only key reproduces wrong language', json.loads(bad['/catalogue'])['language'], 'pt')
 check('variant key selects English', json.loads(good[('/catalogue', 'en')])['language'], 'en')
 profile = request('/profile', {'X-Lab-Tenant': 'A'})
 secret = request('/secret', {'X-Lab-Tenant': 'A'})
 # Conservative policy selection for these unqualified fixtures only.
 def can_store(headers, shared):
 directives = {x.strip for x in headers['cache-control'].split(',')}
 return 'no-store' not in directives and not (shared and 'private' in directives)
 check('private disallows shared storage', can_store(profile[1], True), False)
 check('private may allow private storage', can_store(profile[1], False), True)
 check('no-store disallows both scopes', [can_store(secret[1], True), can_store(secret[1], False)], [False, False])
 check('no-cache may store before validation', can_store(pt[1], True), True)
 state.reset
 _, first = listing('offset')
 state.rows = [r for r in state.rows if r['id']!= 1]
 _, second = listing('offset', first['next_page_token'])
 check('offset deletion skips an unseen item', [ids(first), ids(second)], [[1, 2], [4, 5]])
 state.reset
 _, first = listing('keyset')
 state.rows = [r for r in state.rows if r['id']!= 1]
 _, second = listing('keyset', first['next_page_token'])
 check('keyset survives earlier deletion', [ids(first), ids(second)], [[1, 2], [3, 4]])
 for row in state.rows:
 if row['id'] == 3:
 row['amount'] = 999
 _, replay = listing('keyset', first['next_page_token'])
 check('keyset replay is not a snapshot', replay['items'][0]['amount'], 999)
 state.reset
 _, first = listing('snapshot')
 token = first['next_page_token']
 for row in state.rows:
 if row['id'] == 3:
 row['amount'] = 999
 _, second = listing('snapshot', token)
 check('frozen snapshot retains old value', second['items'][0]['amount'], 30)
 check('cursor cannot change tenant scope', listing('snapshot', token, tenant='B')[0], 400)
 check('cursor cannot change filter scope', listing('snapshot', token, filter_='all')[0], 400)
 check('cursor cannot change mode', listing('keyset', token)[0], 400)
 check('unknown cursor rejected', listing('snapshot', 'not-issued')[0], 400)
 state.allowed.remove('A')
 check('current permission checked before snapshot', listing('snapshot', token)[0], 403)
 state.allowed.add('A')
 state.clock += 10
 check('synthetic cursor expiry', listing('snapshot', token)[0], 410)
 state.reset
 status, page = listing('snapshot')
 collected = ids(page)
 while page['next_page_token']:
 status, page = listing('snapshot', page['next_page_token'])
 collected += ids(page)
 check('complete traversal and terminal token', [collected, page['next_page_token'], status], [[1, 2, 3, 4, 5, 6], '', 200])
 check('tenant B has separate population', ids(listing('keyset', tenant='B')[1]), [101])
 # Pure synthetic arithmetic, separate from the actual HTTP observations.
 check('remaining freshness from supplied age', max(0, 120 - 95), 25)
 check('non-unique sort key drops tie', [r['id'] for r in [{'id': 1, 'time': 9}, {'id': 2, 'time': 9}, {'id': 3, 'time': 10}] if r['time'] > 9], [3])
 tied = [{'id': 1, 'time': 9}, {'id': 2, 'time': 9}, {'id': 3, 'time': 10}]
 check('composite boundary retains unread tie', [r['id'] for r in tied if (r['time'], r['id']) > (9, 1)], [2, 3])
 return {'runtime': platform.python_version, 'transport': 'Actual loopback HTTP/1.1; synthetic identity, state and clock.', 'scope': 'Selected origin responses and pagination behavior; in-memory key and policy models are separate. No TLS, real authentication, proxy implementation, database isolation or production effects.', 'checks': checks, 'passed': len(checks), 'runnerSha256': hashlib.sha256(Path(__file__).read_bytes).hexdigest}
 finally:
 server.shutdown
 server.server_close
 thread.join(timeout=3)


if __name__ == '__main__':
 parser = argparse.ArgumentParser
 parser.add_argument('--output')
 args = parser.parse_args
 result = json.dumps(run, indent=2) + '\n'
 if args.output:
 Path(args.output).write_text(result)
 else:
 print(result, end='')
IN PRACTICE

A cache returns Fundos to an EN request because it stored only /catalogue. The origin can already return Funds; correct variant selection is missing.

Common pitfalls

Treating 304 as empty JSON; confusing no-cache with no-store; treating Authorization as an absolute cache ban; resetting Age to hide staleness.

Related topics: Requests and outcomes · Caching and pagination · Concurrent changes and verifiable recovery

Take this idea with you

A correct cache reuses the permitted representation for the right request within its validation and freshness policy.

Create account

Reference: HTTP Caching · HTTP semantics RFC9110; OpenAPI3.2.1; selected primary standards and provider contracts consulted2026-09-30