Identificar a representação que pode ser reutilizada
Duas respostas com o mesmo URL e código 200 podem ter corpos diferentes. Num portal internacional, Accept-Language pode selecionar PT ou EN; noutro endpoint, a identidade pode determinar dados personalizados. Começa o diagnóstico por registar o pedido efetivo, a representação recebida e a política da resposta. Vary comunica dimensões do pedido relevantes para a seleção de variantes. Não transforma conteúdo reservado em público. A escolha da chave, a permissão de armazenamento e o direito de acesso são decisões distintas que devem formar um contrato coerente.
Ler uma validação sem inventar um documento
O cliente pode guardar corpo e ETag e enviar If-None-Match na leitura seguinte. Um 304 confirma a possibilidade de reutilizar a representação correspondente; não traz um novo documento JSON vazio. O código do consumidor deve manter o corpo e atualizar a metadata aplicável. Se pede EN com o validador de PT, a origem avalia a representação EN selecionada. O laboratório usa ETags diferentes por corpo e demonstra a resposta completa quando muda a variante. Suporta apenas uma entity-tag, sem implementar toda a gramática de listas ou wildcard.
Separar armazenamento de frescura
No-cache sem argumentos permite uma estratégia de armazenamento com validação obrigatória antes da reutilização. No-store proíbe guardar a resposta nos âmbitos aplicáveis e não é uma prova de que cópias antigas desapareceram. Private sem qualificadores impede armazenamento partilhado, mantendo a possibilidade de uma cache privada cumprir as restantes regras. Ao investigar um relatório exposto, corrige a política futura e trata as cópias existentes e o impacto observado. Um header não substitui autenticação, autorização, cifragem ou gestão do incidente. Não uses o tempo de vida curto como justificação para partilhar dados personalizados.
Definir o comportamento quando a origem falha
O orçamento de frescura depende da idade calculada, não do instante em que alguém voltou a abrir o ecrã. No exemplo simplificado, 120 segundos de vida menos 95 segundos de idade deixam 25 segundos. Quando uma resposta stale exige must-revalidate, a falta de ligação à origem não permite apresentá-la silenciosamente como atual. Discute com o dono do serviço se existe um modo histórico ou degradado, quais decisões ficam impedidas e como mostrar a idade. Não mudes a garantia sem tornar explícito o novo requisito.
Executar o laboratório local
Guarda o código desta aula como run.py e executa python3 run.py --output evidence.json numa pasta temporária. O programa abre HTTP apenas em 127.0.0.1 numa porta atribuída pelo sistema e fecha o servidor no fim. Compara PT e EN, valida uma resposta, muda a revisão e observa o novo corpo. Os exemplos de chave incorreta e decisão de armazenamento são modelos em memória separados do transporte HTTP. X-Lab-Tenant é contexto sintético, não autenticação. Não há TLS, CDN real, cache HTTP completa, pedidos bancários ou persistência distribuída.
Aceitar resultados observáveis
Na oficina, prevê o resultado antes de ler a evidência. Explica por que motivo o 304 não substitui o corpo, por que os ETags diferem e onde a chave apenas por URL erra. Repete pedidos em ambas as ordens para evitar uma demonstração que só funciona com cache vazia. Para aplicar o desenho num projeto, identifica os intermediários reais e planeia ensaios autorizados com variantes e identidades distintas. A evidência local ensina o raciocínio, mas não aprova automaticamente a configuração de um produto ou a política de uma instituição.
"""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='')
Uma cache devolve Fundos ao pedido EN porque guardou só /catalogue. A origem já sabe devolver Funds; falta selecionar a variante correta.
Armadilhas comuns
Tratar 304 como JSON vazio; confundir no-cache com no-store; considerar Authorization uma proibição absoluta de cache; reiniciar Age para ocultar stale.
Tópicos relacionados: Pedidos e resultados · Cache e paginação · Alterações concorrentes e recuperação verificável
Uma cache correta reutiliza a representação permitida, para o pedido certo, dentro da política de validação e frescura.
Referência: HTTP Caching · HTTP semantics RFC9110; OpenAPI3.2.1; selected primary standards and provider contracts consulted2026-09-30