Chapitre 1 — HTTP pour les APIs#
HTTP est le protocole de transport universel des APIs web. Le livre Réseaux et protocoles de cette collection couvre le protocole lui-même — versions, handshake TLS, multiplexage HTTP/2. Ce chapitre part de là et s’intéresse à ce qui compte du point de vue du concepteur d’API : les mécanismes HTTP qui structurent la négociation de format, la mise en cache, la sécurité cross-origin, et la pagination.
Négociation de contenu#
La négociation de contenu (content negotiation) est le mécanisme par lequel un client et un serveur s’accordent sur le format de la représentation échangée. Elle repose sur une famille de headers Accept-* envoyés par le client et traités côté serveur.
Les headers Accept#
Le header Accept liste les types MIME que le client est prêt à recevoir. Le header Content-Type indique le format effectif de la représentation transmise.
GET /api/v1/users/42 HTTP/1.1
Accept: application/json, application/xml;q=0.8, */*;q=0.5
Le paramètre q (quality factor) est un réel entre 0 et 1 indiquant la préférence relative. En l’absence de q, la valeur implicite est 1.0. Le serveur doit sélectionner la représentation de qualité maximale parmi celles qu’il peut produire.
Accept-Language fonctionne sur le même principe pour la langue :
Accept-Language: fr-FR, fr;q=0.9, en;q=0.7
Accept-Encoding liste les algorithmes de compression acceptés :
Accept-Encoding: br, gzip;q=0.9, deflate;q=0.5
Implémentation FastAPI#
FastAPI délègue la négociation de contenu à l’application, mais le routing par Accept se configure proprement avec des dépendances :
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse, Response
import xml.etree.ElementTree as ET
app = FastAPI()
def build_xml_user(user: dict) -> str:
root = ET.Element("user")
for key, value in user.items():
child = ET.SubElement(root, key)
child.text = str(value)
return ET.tostring(root, encoding="unicode", xml_declaration=True)
@app.get("/api/v1/users/{user_id}")
async def get_user(user_id: int, request: Request):
user = {"id": user_id, "name": "Alice Martin", "email": "alice@example.com"}
accept = request.headers.get("accept", "application/json")
# Analyse des q-factors
types = []
for part in accept.split(","):
part = part.strip()
if ";q=" in part:
mime, q = part.split(";q=")
types.append((mime.strip(), float(q)))
else:
types.append((part.strip(), 1.0))
types.sort(key=lambda x: x[1], reverse=True)
for mime, _ in types:
if "json" in mime or mime == "*/*":
return JSONResponse(content=user)
if "xml" in mime:
return Response(
content=build_xml_user(user),
media_type="application/xml"
)
raise HTTPException(status_code=406, detail="Not Acceptable")
Le code HTTP 406 Not Acceptable est la réponse correcte lorsqu’aucun format demandé n’est disponible — il est trop souvent omis dans les APIs réelles.
Proactive vs reactive negotiation#
La négociation proactive (ci-dessus) laisse le serveur choisir. La négociation reactive consiste à répondre 300 Multiple Choices avec des liens vers les représentations disponibles, laissant le client choisir. En pratique, les APIs web utilisent quasi-exclusivement la négociation proactive.
Requêtes conditionnelles#
Les requêtes conditionnelles permettent de conditionner l’exécution d’une requête à l’état d’une ressource. Elles réduisent le trafic réseau et permettent l’implémentation de la concurrence optimiste.
If-None-Match et If-Modified-Since#
Le client stocke l’ETag et le renvoie lors des requêtes suivantes :
GET /api/v1/users/42 HTTP/1.1
If-None-Match: "a3f4b2c1"
Si la ressource n’a pas changé, le serveur répond 304 Not Modified sans corps. Le client utilise sa copie en cache. Cette mécanique économise la bande passante et réduit la charge serveur.
If-Modified-Since opère de façon analogue avec une date :
GET /api/v1/users/42 HTTP/1.1
If-Modified-Since: Wed, 18 Mar 2026 10:00:00 GMT
If-Match et verrouillage optimiste#
If-Match conditionne une opération de modification à ce que la ressource soit toujours dans l’état connu du client. C’est la base du verrouillage optimiste :
PUT /api/v1/users/42 HTTP/1.1
If-Match: "a3f4b2c1"
Content-Type: application/json
{"name": "Alice Dupont"}
Si la ressource a été modifiée entre-temps (ETag différent), le serveur répond 412 Precondition Failed. Le client sait alors qu’une mise à jour concurrente a eu lieu et doit re-fetcher avant de retenter.
If-Unmodified-Since fonctionne de même avec une date.
from fastapi import FastAPI, Request, Response, HTTPException
import hashlib, json
app = FastAPI()
db = {"42": {"name": "Alice Martin", "version": 1}}
def compute_etag(data: dict) -> str:
content = json.dumps(data, sort_keys=True).encode()
return f'"{hashlib.sha256(content).hexdigest()[:16]}"'
@app.get("/api/v1/users/{user_id}")
async def get_user(user_id: str, request: Request, response: Response):
if user_id not in db:
raise HTTPException(status_code=404)
user = db[user_id]
etag = compute_etag(user)
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
response.headers["ETag"] = etag
response.headers["Cache-Control"] = "private, max-age=60"
return user
@app.put("/api/v1/users/{user_id}")
async def update_user(user_id: str, body: dict, request: Request, response: Response):
if user_id not in db:
raise HTTPException(status_code=404)
current_etag = compute_etag(db[user_id])
if_match = request.headers.get("if-match")
if if_match and if_match != current_etag:
raise HTTPException(status_code=412, detail="Precondition Failed")
db[user_id].update(body)
db[user_id]["version"] += 1
new_etag = compute_etag(db[user_id])
response.headers["ETag"] = new_etag
return db[user_id]
Caching HTTP avancé pour les APIs#
Le cache HTTP est l’un des mécanismes de performance les plus puissants disponibles sans modification de l’application. La directive Cache-Control en est le pilier.
Directives Cache-Control#
Directive |
Usage |
|---|---|
|
Interdit toute mise en cache (données sensibles) |
|
Autorise le stockage mais exige revalidation avant réutilisation |
|
Cache navigateur uniquement, pas les proxies |
|
Cache partagés (CDN, proxies) autorisés |
|
Durée de fraîcheur en secondes (cache client) |
|
Durée de fraîcheur pour les caches partagés (prioritaire sur |
|
Sert le contenu périmé pendant N secondes tout en revalidant en arrière-plan |
|
Sert le contenu périmé si le serveur est en erreur pendant N secondes |
|
Interdit de servir du contenu périmé si le serveur est accessible |
|
Indique que la ressource ne changera jamais (assets versionnés) |
Le header Vary#
Vary liste les headers de requête qui influencent la représentation. Un cache doit stocker une entrée distincte par combinaison de valeurs de ces headers.
HTTP/1.1 200 OK
Vary: Accept-Encoding, Accept-Language
Cache-Control: public, max-age=3600
Pour les APIs qui font de la négociation de contenu, Vary: Accept est indispensable pour éviter qu’un cache CDN serve du JSON à un client qui demande du XML.
Attention avec Vary
Un Vary: * rend le cache inopérant pour toutes les requêtes. Évitez Vary: Cookie sur les ressources publiques — chaque cookie différent crée une entrée de cache distincte, ce qui fragmente le cache CDN.
Le header Age#
Age indique depuis combien de secondes la réponse se trouve dans le cache intermédiaire. Un client peut ainsi calculer l’âge réel : age_effectif = max-age - Age.
Stratégies pour les APIs#
Données utilisateur :
Cache-Control: private, max-age=60— seul le cache navigateurDonnées publiques statiques :
Cache-Control: public, max-age=3600, s-maxage=86400Données temps réel :
Cache-Control: no-storeouno-cacheRéponses d’erreur : ne jamais cacher les
5xx, limiter les4xxà quelques secondes
# Cellule exécutable — parsing de Cache-Control
import re
def parse_cache_control(header: str) -> dict:
"""Parse un header Cache-Control en dictionnaire structuré."""
directives = {}
for token in re.split(r',\s*', header.strip()):
token = token.strip()
if '=' in token:
key, _, value = token.partition('=')
try:
directives[key.strip().lower()] = int(value.strip())
except ValueError:
directives[key.strip().lower()] = value.strip()
else:
directives[token.lower()] = True
return directives
exemples = [
"public, max-age=3600, s-maxage=86400",
"private, no-cache, must-revalidate",
"no-store",
"public, max-age=600, stale-while-revalidate=60, stale-if-error=3600",
"public, max-age=31536000, immutable",
]
for h in exemples:
parsed = parse_cache_control(h)
print(f" {h!r}")
for k, v in parsed.items():
print(f" {k}: {v}")
print()
CORS en détail#
CORS (Cross-Origin Resource Sharing) est le mécanisme qui autorise une page web servie depuis une origine à effectuer des requêtes vers une API sur une origine différente. Comprendre CORS en détail est indispensable pour les APIs accessibles depuis des applications front-end.
Qu’est-ce qu’une origine ?#
Une origine est le triplet (schéma, hôte, port). https://app.example.com et https://api.example.com sont deux origines distinctes, même s’ils partagent le même domaine parent.
Le preflight#
Pour les requêtes « non simples » (méthodes autres que GET/POST/HEAD, headers personnalisés, Content-Type autre que application/x-www-form-urlencoded/multipart/form-data/text/plain), le navigateur envoie d’abord une requête OPTIONS :
OPTIONS /api/v1/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Le serveur doit répondre avec les permissions :
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Access-Control-Max-Age indique combien de secondes le navigateur peut mettre en cache le résultat du preflight — réduire les requêtes OPTIONS répétées est important pour la performance.
Credentials et wildcards#
Par défaut, CORS n’inclut pas les cookies ni les headers d’authentification. Pour les inclure :
// Côté client
fetch("https://api.example.com/data", { credentials: "include" })
Côté serveur, il faut répondre Access-Control-Allow-Credentials: true ET spécifier une origine explicite — Access-Control-Allow-Origin: * est incompatible avec les credentials.
Wildcard et credentials incompatibles
Un serveur qui répond Access-Control-Allow-Origin: * avec Access-Control-Allow-Credentials: true sera rejeté par le navigateur. Pour les APIs authentifiées, il faut refléter l’origine de la requête après validation (liste blanche).
Configuration FastAPI/Nginx#
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
max_age=86400,
)
Configuration Nginx pour les APIs statiques (sans credentials) :
location /api/ {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
if ($request_method = OPTIONS) {
return 204;
}
}
Range requests#
Les range requests permettent de récupérer une partie d’une ressource. Elles sont fondamentales pour le streaming vidéo, les téléchargements reprenables, et la pagination de ressources binaires.
Négociation du support#
Le serveur annonce son support via Accept-Ranges: bytes. La valeur none indique explicitement l’absence de support.
La requête Range#
GET /api/v1/files/video.mp4 HTTP/1.1
Range: bytes=0-1048575
Syntaxes valides :
bytes=0-1023— octets 0 à 1023 (1024 octets)bytes=1024-— du 1024e octet jusqu’à la finbytes=-512— les 512 derniers octets
La réponse 206#
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1048575/52428800
Content-Length: 1048576
Accept-Ranges: bytes
Content-Type: video/mp4
Si la plage est invalide ou hors limites : 416 Range Not Satisfiable avec Content-Range: bytes */52428800.
Implémentation FastAPI#
from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.responses import StreamingResponse
import os, re
app = FastAPI()
@app.get("/api/v1/files/{filename}")
async def stream_file(filename: str, request: Request):
path = f"/data/files/{filename}"
if not os.path.exists(path):
raise HTTPException(status_code=404)
file_size = os.path.getsize(path)
range_header = request.headers.get("range")
if not range_header:
return StreamingResponse(open(path, "rb"), media_type="video/mp4",
headers={"Accept-Ranges": "bytes",
"Content-Length": str(file_size)})
match = re.match(r"bytes=(\d*)-(\d*)", range_header)
if not match:
raise HTTPException(status_code=416)
start = int(match.group(1)) if match.group(1) else file_size - int(match.group(2) or 0)
end = int(match.group(2)) if match.group(2) else file_size - 1
if start > end or end >= file_size:
return Response(
status_code=416,
headers={"Content-Range": f"bytes */{file_size}"}
)
chunk_size = end - start + 1
def iterate_file():
with open(path, "rb") as f:
f.seek(start)
remaining = chunk_size
while remaining:
data = f.read(min(65536, remaining))
if not data:
break
remaining -= len(data)
yield data
return StreamingResponse(
iterate_file(),
status_code=206,
media_type="video/mp4",
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(chunk_size),
"Accept-Ranges": "bytes",
}
)
Compression#
La compression réduit la taille des réponses HTTP. Pour les APIs qui retournent des charges utiles JSON volumineuses, le gain est substantiel (60-80 % sur du JSON typique).
Gzip vs Brotli#
Gzip (RFC 1952) est universel, supporté par tous les clients depuis 20 ans. Le ratio de compression est typiquement 60-70 % sur du JSON.
Brotli (RFC 7932), développé par Google, offre un ratio supérieur (65-80 % sur du JSON) pour un temps de compression légèrement plus élevé. Supporté par tous les navigateurs modernes et la majorité des clients HTTP récents.
Quand compresser ?#
Règles pratiques
Ne compressez pas les réponses inférieures à 1 Ko — le coût CPU dépasse le gain réseau
Ne compressez pas les formats déjà compressés : JPEG, PNG, MP4, PDF, ZIP
Activez toujours la compression pour : JSON, XML, HTML, CSS, JavaScript, texte brut
Préférez Brotli si le client le supporte (
brdansAccept-Encoding)En Nginx :
gzip_min_length 1024; gzip_types application/json text/plain application/xml;
Configuration FastAPI#
FastAPI ne compresse pas par défaut. Il faut ajouter un middleware ou déléguer à Nginx/un proxy :
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
Pour Brotli, utiliser asgi-brotli ou configurer Nginx en amont.
Headers de sécurité pour les APIs#
Les headers de sécurité HTTP sont essentiels même pour les APIs JSON pures — certains s’appliquent au contexte de l’API elle-même, d’autres protègent les éventuelles interfaces d’administration.
Strict-Transport-Security (HSTS)#
Force les clients à utiliser HTTPS pendant la durée spécifiée. Indispensable pour toute API en production.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
preload soumet le domaine à la liste HSTS preload des navigateurs — à n’ajouter qu’avec certitude que tout le domaine est HTTPS.
X-Content-Type-Options#
Empêche le navigateur d’inférer le Content-Type (MIME sniffing) :
X-Content-Type-Options: nosniff
Particulièrement important pour les APIs qui servent des fichiers uploadés par des utilisateurs.
Referrer-Policy#
Contrôle quelles informations de référent sont transmises. Pour une API :
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy#
Anciennement Feature-Policy, restreint les fonctionnalités navigateur. Pertinent si l’API sert aussi des ressources HTML.
Permissions-Policy: geolocation=(), camera=(), microphone=()
X-Frame-Options#
Protège contre le clickjacking. Pour une API pure JSON, le risque est faible, mais pour les endpoints d’authentification (pages de login OAuth) :
X-Frame-Options: DENY
Configuration FastAPI centralisée#
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Permissions-Policy"] = "geolocation=(), camera=()"
return response
app = FastAPI()
app.add_middleware(SecurityHeadersMiddleware)
Pagination et headers#
La pagination est un contrat entre l’API et ses consommateurs. HTTP fournit des mécanismes standardisés souvent sous-utilisés.
Le header Link (RFC 5988)#
Le header Link encode des relations hypermédias entre ressources. Pour la pagination :
HTTP/1.1 200 OK
Link: <https://api.example.com/users?page=3>; rel="next",
<https://api.example.com/users?page=1>; rel="prev",
<https://api.example.com/users?page=1>; rel="first",
<https://api.example.com/users?page=20>; rel="last"
X-Total-Count: 1943
Les relations next, prev, first, last sont standardisées. GitHub, GitLab et de nombreuses APIs majeures utilisent ce pattern.
Headers X-RateLimit#
Bien qu’il n’existe pas de standard officiel, la convention de facto est :
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 743
X-RateLimit-Reset: 1742900400
Retry-After: 3600
X-RateLimit-Reset est un timestamp Unix. Retry-After (utilisé avec 429 Too Many Requests) peut être soit un entier (secondes à attendre) soit une date HTTP.
Draft IETF RateLimit Headers
Un draft IETF standardise ces headers sous les noms RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. Certaines APIs récentes l’adoptent par anticipation.
Implémentation FastAPI#
from fastapi import FastAPI
from fastapi.responses import JSONResponse
import math
app = FastAPI()
fake_db_total = 1943
page_size = 50
@app.get("/api/v1/users")
async def list_users(page: int = 1, size: int = 50):
size = min(size, 100)
total = fake_db_total
total_pages = math.ceil(total / size)
base_url = "https://api.example.com/api/v1/users"
users = [{"id": i, "name": f"User {i}"}
for i in range((page - 1) * size + 1, min(page * size + 1, total + 1))]
links = []
if page > 1:
links.append(f'<{base_url}?page={page-1}&size={size}>; rel="prev"')
links.append(f'<{base_url}?page=1&size={size}>; rel="first"')
if page < total_pages:
links.append(f'<{base_url}?page={page+1}&size={size}>; rel="next"')
links.append(f'<{base_url}?page={total_pages}&size={size}>; rel="last"')
headers = {
"X-Total-Count": str(total),
"X-Page": str(page),
"X-Per-Page": str(size),
}
if links:
headers["Link"] = ", ".join(links)
return JSONResponse(content=users, headers=headers)
Cellules d’analyse et de visualisation#
Analyse de directives Cache-Control#
import re
import json
def parse_cache_control(header: str) -> dict:
directives = {}
for token in re.split(r',\s*', header.strip()):
token = token.strip()
if '=' in token:
key, _, value = token.partition('=')
try:
directives[key.strip().lower()] = int(value.strip())
except ValueError:
directives[key.strip().lower()] = value.strip()
else:
if token:
directives[token.lower()] = True
return directives
def describe_strategy(directives: dict) -> str:
if directives.get("no-store"):
return "Aucune mise en cache autorisée"
if directives.get("private"):
scope = "Cache navigateur uniquement"
elif directives.get("public"):
scope = "Cache public (CDN/proxy autorisé)"
else:
scope = "Scope non spécifié"
if directives.get("immutable"):
return f"{scope} — ressource immuable (assets versionnés)"
max_age = directives.get("max-age", directives.get("s-maxage"))
if max_age is not None:
if max_age == 0:
freshness = "revalidation systématique"
elif max_age < 60:
freshness = f"fraîcheur {max_age}s (très courte)"
elif max_age < 3600:
freshness = f"fraîcheur {max_age//60}min"
else:
freshness = f"fraîcheur {max_age//3600}h"
else:
freshness = "durée non spécifiée"
extras = []
if "stale-while-revalidate" in directives:
extras.append(f"SWR={directives['stale-while-revalidate']}s")
if "stale-if-error" in directives:
extras.append(f"SIE={directives['stale-if-error']}s")
if directives.get("no-cache"):
extras.append("revalidation obligatoire")
if directives.get("must-revalidate"):
extras.append("interdiction de servir périmé")
result = f"{scope} — {freshness}"
if extras:
result += f" [{', '.join(extras)}]"
return result
exemples = {
"API utilisateur (données privées)":
"private, no-cache, must-revalidate",
"Page publique avec CDN":
"public, max-age=600, s-maxage=86400, stale-while-revalidate=60",
"Asset versionné":
"public, max-age=31536000, immutable",
"Données sensibles":
"no-store",
"Réponse avec tolérance aux pannes":
"public, max-age=3600, stale-if-error=86400",
"Endpoint temps réel":
"no-cache, no-store",
}
print(f"{'Cas d\'usage':<38} {'Stratégie détectée'}")
print("-" * 90)
for label, header in exemples.items():
parsed = parse_cache_control(header)
desc = describe_strategy(parsed)
print(f" {label:<36} {desc}")
print()
print("Détail du parsing pour le cas CDN :")
parsed_cdn = parse_cache_control("public, max-age=600, s-maxage=86400, stale-while-revalidate=60")
print(json.dumps(parsed_cdn, indent=2))
Cas d'usage Stratégie détectée
------------------------------------------------------------------------------------------
API utilisateur (données privées) Cache navigateur uniquement — durée non spécifiée [revalidation obligatoire, interdiction de servir périmé]
Page publique avec CDN Cache public (CDN/proxy autorisé) — fraîcheur 10min [SWR=60s]
Asset versionné Cache public (CDN/proxy autorisé) — ressource immuable (assets versionnés)
Données sensibles Aucune mise en cache autorisée
Réponse avec tolérance aux pannes Cache public (CDN/proxy autorisé) — fraîcheur 1h [SIE=86400s]
Endpoint temps réel Aucune mise en cache autorisée
Détail du parsing pour le cas CDN :
{
"public": true,
"max-age": 600,
"s-maxage": 86400,
"stale-while-revalidate": 60
}
Diagramme de séquence — conditional requests#
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, ax = plt.subplots(figsize=(11, 8))
ax.set_xlim(0, 10)
ax.set_ylim(0, 14)
ax.axis("off")
ax.set_facecolor("#fafafa")
fig.patch.set_facecolor("#fafafa")
# Entités
actors = {"Client": 2, "Cache": 5, "Serveur": 8}
for name, x in actors.items():
ax.text(x, 13.5, name, ha="center", va="center", fontsize=12,
fontweight="bold",
bbox=dict(boxstyle="round,pad=0.4", facecolor="#4C72B0",
edgecolor="none", alpha=0.85))
ax.plot([x, x], [0.5, 13.2], color="#4C72B0", lw=1.2,
linestyle="--", alpha=0.5)
def arrow(ax, x1, x2, y, label, color="#333333", style="->"):
ax.annotate("", xy=(x2, y), xytext=(x1, y),
arrowprops=dict(arrowstyle=style, color=color, lw=1.5))
mid = (x1 + x2) / 2
ax.text(mid, y + 0.25, label, ha="center", va="bottom",
fontsize=8.5, color=color)
def note_box(ax, x, y, text, color="#E8F4FD"):
ax.text(x, y, text, ha="center", va="center", fontsize=8,
bbox=dict(boxstyle="round,pad=0.3", facecolor=color,
edgecolor="#aaaaaa", alpha=0.9))
# Séquence 1 : première requête
note_box(ax, 5, 12.5, "① Première requête — pas de cache", color="#FFF3CD")
arrow(ax, 2, 5, 12.0, "GET /resource", "#2196F3")
arrow(ax, 5, 8, 11.5, "GET /resource (forward)", "#2196F3")
arrow(ax, 8, 5, 11.0, '200 OK ETag: "abc123" max-age=300', "#4CAF50")
arrow(ax, 5, 2, 10.5, '200 OK ETag: "abc123" (stocké en cache)', "#4CAF50")
# Séquence 2 : requête dans le délai (cache hit)
note_box(ax, 5, 9.8, "② Requête suivante < 300s — cache HIT", color="#FFF3CD")
arrow(ax, 2, 5, 9.3, "GET /resource", "#2196F3")
note_box(ax, 5, 8.9, "Cache frais — pas de requête serveur", color="#E8F5E9")
arrow(ax, 5, 2, 8.4, "200 OK (depuis cache, Age: 45)", "#4CAF50")
# Séquence 3 : cache expiré, revalidation
note_box(ax, 5, 7.7, "③ Cache expiré — revalidation conditionnelle", color="#FFF3CD")
arrow(ax, 2, 5, 7.2, "GET /resource", "#2196F3")
arrow(ax, 5, 8, 6.7, 'GET /resource If-None-Match: "abc123"', "#FF9800")
arrow(ax, 8, 5, 6.2, "304 Not Modified (sans corps)", "#9C27B0")
arrow(ax, 5, 2, 5.7, "200 OK (depuis cache, rafraîchi)", "#4CAF50")
# Séquence 4 : ressource modifiée
note_box(ax, 5, 5.0, "④ Ressource modifiée côté serveur", color="#FFF3CD")
arrow(ax, 2, 5, 4.5, "GET /resource", "#2196F3")
arrow(ax, 5, 8, 4.0, 'GET /resource If-None-Match: "abc123"', "#FF9800")
arrow(ax, 8, 5, 3.5, '200 OK ETag: "xyz789" (nouvelle version)', "#F44336")
arrow(ax, 5, 2, 3.0, '200 OK ETag: "xyz789" (cache mis à jour)', "#F44336")
ax.set_title("Flux des requêtes conditionnelles HTTP avec ETag",
fontsize=13, fontweight="bold", pad=10)
plt.savefig("conditional_requests_sequence.png", dpi=120,
bbox_inches="tight", facecolor="#fafafa")
plt.show()
Flowchart des stratégies de caching#
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_xlim(0, 10)
ax.set_ylim(0, 14)
ax.axis("off")
fig.patch.set_facecolor("#f8f9fa")
ax.set_facecolor("#f8f9fa")
colors = {
"decision": "#4C72B0",
"action_no": "#DD8452",
"action_yes": "#55A868",
"terminal": "#C44E52",
}
def box(ax, x, y, w, h, text, color, fontsize=9, shape="round"):
patch = mpatches.FancyBboxPatch(
(x - w/2, y - h/2), w, h,
boxstyle=f"round,pad=0.15",
facecolor=color, edgecolor="white", linewidth=1.5, alpha=0.9
)
ax.add_patch(patch)
ax.text(x, y, text, ha="center", va="center", fontsize=fontsize,
color="white", fontweight="bold", wrap=True,
multialignment="center")
def arr(ax, x1, y1, x2, y2, label="", color="#555555"):
ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
arrowprops=dict(arrowstyle="-|>", color=color,
lw=1.3, mutation_scale=14))
if label:
mx, my = (x1+x2)/2, (y1+y2)/2
ax.text(mx + 0.15, my, label, fontsize=8, color=color, va="center")
# Nœuds
box(ax, 5, 13.2, 4, 0.65, "Nouvelle requête API", "#2C3E50", fontsize=10)
box(ax, 5, 12.0, 4.5, 0.65, "Données sensibles\n(auth, PII, paiement) ?",
colors["decision"], fontsize=9)
box(ax, 8.5, 12.0, 2.2, 0.55, "no-store", colors["terminal"], fontsize=9)
box(ax, 5, 10.7, 4.5, 0.65, "Données spécifiques\nà l'utilisateur ?",
colors["decision"], fontsize=9)
box(ax, 8.5, 10.7, 2.2, 0.65, "private\nmax-age=60–300", colors["action_no"], fontsize=8)
box(ax, 5, 9.4, 4.5, 0.65, "Données temps réel\n(< 1 min de fraîcheur) ?",
colors["decision"], fontsize=9)
box(ax, 8.5, 9.4, 2.2, 0.65, "no-cache\nmust-revalidate", colors["action_no"], fontsize=8)
box(ax, 5, 8.1, 4.5, 0.65, "Données publiques partagées\n(entre utilisateurs) ?",
colors["decision"], fontsize=9)
box(ax, 5, 6.8, 4.5, 0.65, "Servie via CDN\nou proxy partagé ?",
colors["decision"], fontsize=9)
box(ax, 8.5, 6.8, 2.2, 0.65, "public\nmax-age=300", colors["action_yes"], fontsize=8)
box(ax, 5, 5.5, 4.5, 0.65, "Ressource immuable\n(asset versionné) ?",
colors["decision"], fontsize=9)
box(ax, 8.5, 5.5, 2.2, 0.65, "public, immutable\nmax-age=31536000", colors["action_yes"], fontsize=8)
box(ax, 5, 4.2, 4.5, 0.75,
"public\nmax-age=600, s-maxage=86400\nstale-while-revalidate=60",
colors["action_yes"], fontsize=8)
# Flèches verticales
arr(ax, 5, 13.0, 5, 12.35)
arr(ax, 5, 11.68, 5, 11.05)
arr(ax, 5, 10.38, 5, 9.75)
arr(ax, 5, 9.07, 5, 8.45)
arr(ax, 5, 7.78, 5, 7.15)
arr(ax, 5, 6.48, 5, 5.85)
arr(ax, 5, 5.18, 5, 4.62)
# Flèches latérales (oui)
arr(ax, 7.25, 12.0, 7.4, 12.0, "Oui", "#C44E52")
arr(ax, 7.25, 10.7, 7.4, 10.7, "Oui", "#C44E52")
arr(ax, 7.25, 9.4, 7.4, 9.4, "Oui", "#C44E52")
arr(ax, 7.25, 6.8, 7.4, 6.8, "Oui", "#C44E52")
arr(ax, 7.25, 5.5, 7.4, 5.5, "Oui", "#C44E52")
# Labels Non
for y in [12.0, 10.7, 9.4, 8.1, 6.8, 5.5]:
ax.text(5.25, y - 0.45, "Non", fontsize=8, color="#55A868")
ax.set_title("Arbre de décision — stratégies de caching HTTP pour les APIs",
fontsize=12, fontweight="bold", pad=8)
plt.savefig("caching_decision_tree.png", dpi=120,
bbox_inches="tight", facecolor="#f8f9fa")
plt.show()
Taux de compression par type de contenu#
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
types_contenu = [
"JSON (API générique)",
"JSON (données répétitives)",
"XML verbose",
"HTML",
"JavaScript minifié",
"Texte brut (logs)",
"CSV (données numériques)",
"Image JPEG",
"Archive ZIP",
"Binaire protobuf",
]
# Taux de réduction de taille (%) — données simulées réalistes
gzip_ratio = [65, 78, 72, 68, 55, 70, 74, 2, 1, 25]
brotli_ratio = [72, 84, 78, 74, 62, 76, 80, 2, 1, 30]
df = pd.DataFrame({
"Type de contenu": types_contenu * 2,
"Algorithme": ["gzip"] * len(types_contenu) + ["brotli"] * len(types_contenu),
"Réduction (%)": gzip_ratio + brotli_ratio,
})
fig, ax = plt.subplots(figsize=(11, 6.5))
x = np.arange(len(types_contenu))
width = 0.38
palette = sns.color_palette("muted")
bars_gzip = ax.barh(x + width/2, gzip_ratio, height=width,
label="gzip", color=palette[0], alpha=0.88)
bars_brotli = ax.barh(x - width/2, brotli_ratio, height=width,
label="brotli", color=palette[1], alpha=0.88)
# Valeurs
for bar in bars_gzip:
w = bar.get_width()
if w > 5:
ax.text(w + 0.5, bar.get_y() + bar.get_height()/2,
f"{w}%", va="center", fontsize=8.5)
for bar in bars_brotli:
w = bar.get_width()
if w > 5:
ax.text(w + 0.5, bar.get_y() + bar.get_height()/2,
f"{w}%", va="center", fontsize=8.5)
ax.set_yticks(x)
ax.set_yticklabels(types_contenu, fontsize=9.5)
ax.set_xlabel("Réduction de taille (%)", fontsize=10)
ax.set_title("Taux de compression gzip vs brotli par type de contenu",
fontsize=12, fontweight="bold", pad=10)
ax.axvline(x=50, color="#aaaaaa", linestyle="--", linewidth=0.8, alpha=0.7,
label="Seuil de rentabilité indicatif (50%)")
ax.set_xlim(0, 95)
ax.legend(loc="lower right", fontsize=9.5)
plt.savefig("compression_comparison.png", dpi=120,
bbox_inches="tight")
plt.show()
print("\nRésumé — gain brotli vs gzip :")
for t, g, b in zip(types_contenu, gzip_ratio, brotli_ratio):
diff = b - g
if diff > 0:
print(f" {t:<38} brotli +{diff}pp vs gzip")
else:
print(f" {t:<38} équivalent")
Résumé — gain brotli vs gzip :
JSON (API générique) brotli +7pp vs gzip
JSON (données répétitives) brotli +6pp vs gzip
XML verbose brotli +6pp vs gzip
HTML brotli +6pp vs gzip
JavaScript minifié brotli +7pp vs gzip
Texte brut (logs) brotli +6pp vs gzip
CSV (données numériques) brotli +6pp vs gzip
Image JPEG équivalent
Archive ZIP équivalent
Binaire protobuf brotli +5pp vs gzip
Résumé#
Ce chapitre a couvert les mécanismes HTTP qui structurent la conception d’une API de qualité :
Négociation de contenu — le tandem Accept/Content-Type avec q-factors permet à une même API de servir plusieurs formats. Le code 406 Not Acceptable est la réponse correcte en cas d’incompatibilité.
Requêtes conditionnelles — les ETags et If-None-Match économisent la bande passante et constituent la base du verrouillage optimiste avec If-Match / 412 Precondition Failed.
Cache-Control avancé — stale-while-revalidate et stale-if-error sont les directives les plus utiles pour les APIs exposées via CDN. Vary est indispensable dès qu’une API pratique la négociation de contenu.
CORS — les wildcards sont incompatibles avec les credentials. Pour les APIs authentifiées, la liste blanche d’origines avec réflexion conditionnelle est le seul pattern correct.
Range requests — 206 Partial Content permet le streaming repris et la pagination de ressources binaires volumineuses.
Compression — brotli surpasse gzip sur le JSON (72 % vs 65 % de réduction typique). Ne pas compresser en dessous de 1 Ko ni les formats déjà compressés.
Headers de sécurité — HSTS, X-Content-Type-Options: nosniff, et Referrer-Policy forment le minimum pour toute API en production.
Pagination — le header Link (RFC 5988) avec les relations next/prev/first/last est le standard de facto ; X-Total-Count complète le tableau.