Módulo 6: Optimistic Locking + Schema Versioning
`If-Match` header y HTTP 412: optimistic concurrency a nivel HTTP
Hasta ahora, el cliente envía version en el body del request: {"title": "New", "version": 5}. Funciona, pero hay una alternativa que encaja mejor con HTTP y es estándar desde 1997: If-Match header (RFC 7232) y respuesta 412 Precondition Failed.
Con If-Match, el cliente envía la versión en headers, no en body. El server valida la condición HTTP antes de procesar. Si no coincide, devuelve 412 (no 409). La diferencia parece sutil pero importa: encaja con caches HTTP estándar, permite usar ETags, separa metadata (version) del payload, y hace tu API más interoperable con herramientas estándar.
En esta cápsula vas a aprender el estándar If-Match, la distinción entre 412 y 409, cómo implementarlo en FastAPI, y cuándo elegir entre header If-Match y body version. La mayoría de APIs production-ready modernas usan ambos en distintos contextos — vas a entender cuál encaja en cada caso.
El estándar HTTP: ETags + If-Match
RFC 7232 define dos primitivas:
ETag header (response): identificador opaco de la versión actual del recurso. El server lo incluye en cada response GET/PUT.
GET /tasks/123 HTTP/1.1
HTTP/1.1 200 OK
ETag: "5"
Content-Type: application/json
{"id": 123, "title": "Original", "version": 5}
If-Match header (request): el cliente envía el ETag que tenía. El server valida: si coincide con el ETag actual, procede; si no, falla con 412.
PUT /tasks/123 HTTP/1.1
If-Match: "5"
Content-Type: application/json
{"title": "New title"}
HTTP/1.1 412 Precondition Failed
{"error": "version_mismatch", "current_etag": "7", ...}
Si la versión coincide:
PUT /tasks/123 HTTP/1.1
If-Match: "5"
Content-Type: application/json
{"title": "New title"}
HTTP/1.1 200 OK
ETag: "6"
Content-Type: application/json
{"id": 123, "title": "New title", "version": 6}
ETags son opacos para el cliente — tipicamente strings entre comillas. Pueden ser:
- Counter como string:
"5","6". - Hash del contenido:
"a3b4c5d6". - Timestamp:
"2026-05-08T14:32:11Z".
Para optimistic locking, counter es lo más simple y mapea directamente a tu version column.
412 Precondition Failed vs 409 Conflict
Las dos son válidas para optimistic locking, con matiz semántico:
412 Precondition Failed: el cliente envió una precondición HTTP (If-Match) que no se cumplió. El cliente puede inferir que el recurso cambió.
409 Conflict: hay un conflict que el cliente debe resolver. Puede ser por version mismatch o por otras razones (duplicate key, etc.).
Convención común:
412cuando el cliente usóIf-Matchy la condición falló — código específico para esa situación.409cuando la versión va en el body (no en header) o cuando hay otros tipos de conflict.
Algunos APIs usan solo 409 (más simple). Otros usan ambos. La distinción es útil porque clientes pueden tener handlers separados:
catch (e) {
if (e.status === 412) {
// Pre-condition fallida — re-fetch y reintentar
} else if (e.status === 409) {
// Conflict de dominio — mostrar UI específica
}
}
Para este módulo, vamos a usar 412 cuando se usa If-Match y 409 cuando version está en body.
Implementación en FastAPI
from fastapi import APIRouter, Depends, HTTPException, Header, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.exc import StaleDataError
from typing import Optional
router = APIRouter()
@router.get("/tasks/{task_id}")
async def get_task(
task_id: int,
response: Response,
db: AsyncSession = Depends(get_db),
):
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404, "Not found")
# Setear ETag header
response.headers["ETag"] = f'"{task.version}"'
return {
"id": task.id,
"title": task.title,
"status": task.status,
# No incluir version en el body — está en ETag header
}
@router.put("/tasks/{task_id}")
async def update_task(
task_id: int,
update_data: TaskUpdateBody, # Sin version aquí
response: Response,
db: AsyncSession = Depends(get_db),
if_match: Optional[str] = Header(None, alias="If-Match"),
):
if not if_match:
raise HTTPException(
status.HTTP_428_PRECONDITION_REQUIRED,
detail="If-Match header required for updates",
)
# Parsear ETag (quitar comillas)
requested_version = int(if_match.strip('"'))
# Fetch
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404, "Not found")
# Pre-check para devolver 412 (más temprano = mejor)
if task.version != requested_version:
raise HTTPException(
status.HTTP_412_PRECONDITION_FAILED,
detail={
"error": "version_mismatch",
"current_etag": f'"{task.version}"',
"your_etag": if_match,
"current_state": {
"id": task.id,
"title": task.title,
"status": task.status,
}
}
)
# Aplicar cambios
for field, value in update_data.model_dump(exclude_unset=True).items():
setattr(task, field, value)
try:
await db.commit()
except StaleDataError:
# Race entre check y commit (raro pero posible)
await db.rollback()
current_task = await db.get(Task, task_id)
raise HTTPException(
status.HTTP_412_PRECONDITION_FAILED,
detail={
"error": "version_mismatch_race",
"current_etag": f'"{current_task.version}"',
# ... resto de la respuesta rica
}
)
# Setear nuevo ETag en respuesta
response.headers["ETag"] = f'"{task.version}"'
return {
"id": task.id,
"title": task.title,
"status": task.status,
}
Cosas importantes:
428 Precondition Required: si el cliente no envíaIf-Match, devolver 428 (RFC 6585). Esto fuerza al cliente a usar el patrón.- Quitar comillas del ETag:
If-Match: "5"viene literal con comillas. Strip antes de parsear. - Pre-check + post-check: el pre-check devuelve 412 inmediatamente sin tocar el UPDATE; el post-check (
StaleDataError) cubre el race entre check y commit. - ETag en respuesta GET y PUT: el cliente recibe el ETag actualizado en cada response y lo usa en el siguiente request.
Patrón completo del cliente
class TaskClient {
constructor() {
this.etags = new Map();
}
async get(taskId) {
const response = await fetch(`/tasks/${taskId}`);
const etag = response.headers.get("ETag");
if (etag) {
this.etags.set(taskId, etag);
}
return await response.json();
}
async update(taskId, changes) {
const etag = this.etags.get(taskId);
if (!etag) {
throw new Error("Must GET before PUT");
}
const response = await fetch(`/tasks/${taskId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"If-Match": etag,
},
body: JSON.stringify(changes),
});
if (response.status === 412) {
const body = await response.json();
throw new ConflictError(body);
}
if (response.status === 200) {
const newEtag = response.headers.get("ETag");
this.etags.set(taskId, newEtag);
return await response.json();
}
throw new Error(`Unexpected status: ${response.status}`);
}
}
El cliente mantiene el ETag automáticamente. El usuario nunca ve "version" — solo "task". El protocolo es transparent.
Casos donde body version es preferible a If-Match
Caso 1: PATCH parcial complejo
Si tu PATCH acepta operaciones complejas (JSON Patch RFC 6902), el body ya tiene mucha estructura. Agregar version al body es natural:
PATCH /tasks/123 HTTP/1.1
Content-Type: application/json-patch+json
{
"version": 5,
"patches": [
{"op": "replace", "path": "/status", "value": "completed"},
{"op": "add", "path": "/tags/-", "value": "urgent"}
]
}
Caso 2: APIs no-HTTP (WebSocket, gRPC)
Si tu app usa WebSocket o gRPC, no hay "headers HTTP". version en payload es la única opción.
Caso 3: bulk operations
Para bulk update de múltiples recursos, cada uno tiene su version:
POST /tasks/bulk-update HTTP/1.1
Content-Type: application/json
{
"updates": [
{"id": 1, "version": 3, "status": "completed"},
{"id": 2, "version": 5, "status": "completed"},
{"id": 3, "version": 1, "status": "completed"}
]
}
If-Match es por-request, no soporta esto naturally.
Caso 4: GraphQL
GraphQL no usa HTTP semantics ricamente. version en input es estándar.
ETag con hash en lugar de version counter
Algunos APIs prefieren ETag basado en hash del contenido:
import hashlib
import json
def compute_etag(task: Task) -> str:
canonical = json.dumps({
"title": task.title,
"status": task.status,
"assigned_to": task.assigned_to,
}, sort_keys=True)
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
Ventajas:
- No necesitas columna
versionen DB. - ETag cambia solo si el contenido cambió (idempotent updates no cambian ETag).
Desventajas:
- Cómputo en cada GET (cacheable pero más lógica).
- Sin counter monotónico — no puedes ordenar versions cronológicamente.
- Más difícil de debuggear ("¿qué version tiene la fila?").
Counter es default. Hash solo si tienes razones específicas (caches HTTP que dependen de hash exacto, idempotency consideraciones).
Caches HTTP y If-None-Match
ETags también habilitan caches HTTP. El cliente puede preguntar "¿cambió desde la última vez?":
GET /tasks/123 HTTP/1.1
If-None-Match: "5"
HTTP/1.1 304 Not Modified
ETag: "5"
Si la versión es la misma, server devuelve 304 sin body — ahorro de bandwidth. Útil para apps que polean recursos frecuentemente.
If-None-Match (cache) y If-Match (concurrency) son complementarios. Mismo ETag, distintas precondiciones.
@router.get("/tasks/{task_id}")
async def get_task(
task_id: int,
response: Response,
db: AsyncSession = Depends(get_db),
if_none_match: Optional[str] = Header(None, alias="If-None-Match"),
):
task = await db.get(Task, task_id)
current_etag = f'"{task.version}"'
if if_none_match == current_etag:
# Cliente tiene la última version — 304 sin body
response.status_code = status.HTTP_304_NOT_MODIFIED
response.headers["ETag"] = current_etag
return None
response.headers["ETag"] = current_etag
return {...}
Trampas y errores comunes
1. Olvidar setear ETag en responses GET.
Sin ETag en GET, el cliente no tiene el valor para usar en If-Match. Validar que GET siempre devuelve ETag.
2. ETag sin comillas.
RFC requiere comillas: ETag: "5". Sin comillas (ETag: 5), algunos clientes ignoran. Siempre con comillas.
3. Confundir If-Match con If-None-Match.
If-Match: "ejecuta solo si coincide" (concurrency).
If-None-Match: "ejecuta solo si NO coincide" (cache).
Misma ETag, opposite semantics.
4. Devolver 409 en lugar de 412 con If-Match.
Técnicamente válido pero desperdicia el código 412 que existe específicamente para esto. Usar 412 cuando el fallo es por header If-Match.
5. No validar formato del header.
# ❌
requested_version = int(if_match.strip('"'))
# Crashea si if_match es "abc" o "" o None
# ✅
try:
requested_version = int(if_match.strip('"'))
except (ValueError, AttributeError):
raise HTTPException(400, "Invalid If-Match header format")
6. ETag exponiendo el valor interno de version.
Counter como ETag está bien. Pero si tu version es algo sensible (ej: información sobre cuántas modificaciones — competidores podrían inferir actividad), usar hash anonymizado.
7. ETag para listings.
Para GET /tasks/ (lista), el ETag es del listing completo. Cualquier cambio en cualquier item invalida el cache. Considerar si vale la pena — para listings dinámicos, no.
8. Mezclar If-Match con body version sin razón clara.
Decidir uno u otro y mantener consistencia en la API. Mezclar confunde clientes.
Decisión: header If-Match vs body version
| Criterio | If-Match | Body version |
|---|---|---|
| Estándar HTTP | ✅ RFC 7232 | Custom |
| Caches HTTP | ✅ Compatible | No |
| Tooling estándar | ✅ Postman, curl, etc | Más manual |
| Bulk updates | Difícil | ✅ Natural |
| WebSocket / gRPC | No aplica | ✅ |
| GraphQL | No aplica | ✅ |
| Visibilidad para devs | "¿Qué es ese header?" | Más explícito en body |
Recomendación: para REST APIs típicas, If-Match. Para casos específicos (bulk, GraphQL, WebSocket), body version. Documentar consistente.
Ejercicio: implementar If-Match end-to-end
Setup: modelo Task con version_id_col (cápsula 03).
Paso 1: implementar el endpoint GET con ETag.
@router.get("/tasks/{task_id}")
async def get_task_with_etag(
task_id: int,
response: Response,
db: AsyncSession = Depends(get_db),
):
# ... implementar
pass
Verificar con curl:
curl -i http://localhost:8000/tasks/1
# HTTP/1.1 200 OK
# ETag: "5"
# ...
Paso 2: implementar PUT con If-Match.
@router.put("/tasks/{task_id}")
async def update_task_with_if_match(
task_id: int,
update_data: TaskUpdateBody,
response: Response,
db: AsyncSession = Depends(get_db),
if_match: Optional[str] = Header(None, alias="If-Match"),
):
# ... implementar
pass
Paso 3: probar el flow completo.
# GET
curl -i http://localhost:8000/tasks/1
# Capturar ETag
# PUT con ETag correcto
curl -i -X PUT http://localhost:8000/tasks/1 \
-H "If-Match: \"5\"" \
-H "Content-Type: application/json" \
-d '{"title": "Updated"}'
# 200 OK, nuevo ETag
# PUT con ETag viejo
curl -i -X PUT http://localhost:8000/tasks/1 \
-H "If-Match: \"5\"" \
-H "Content-Type: application/json" \
-d '{"title": "Stale update"}'
# 412 Precondition Failed
Paso 4: probar sin If-Match.
curl -i -X PUT http://localhost:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "No header"}'
# 428 Precondition Required
Paso 5: implementar If-None-Match para cache.
@router.get("/tasks/{task_id}")
async def get_task_cacheable(
task_id: int,
response: Response,
db: AsyncSession = Depends(get_db),
if_none_match: Optional[str] = Header(None, alias="If-None-Match"),
):
# ... 304 si match
pass
Probar:
curl -i http://localhost:8000/tasks/1 \
-H "If-None-Match: \"5\""
# 304 Not Modified (sin body)
Ver discusión
Paso 1 — GET con ETag:
@router.get("/tasks/{task_id}")
async def get_task(task_id, response, db):
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404)
response.headers["ETag"] = f'"{task.version}"'
return {"id": task.id, "title": task.title, "status": task.status}
Paso 2 — PUT con If-Match:
@router.put("/tasks/{task_id}")
async def update_task(task_id, update_data, response, db, if_match=Header(None, alias="If-Match")):
if not if_match:
raise HTTPException(428, "If-Match required")
try:
requested_version = int(if_match.strip('"'))
except ValueError:
raise HTTPException(400, "Invalid ETag")
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404)
if task.version != requested_version:
raise HTTPException(412, detail={
"error": "version_mismatch",
"current_etag": f'"{task.version}"',
"current_state": {...}
})
for field, value in update_data.model_dump(exclude_unset=True).items():
setattr(task, field, value)
try:
await db.commit()
except StaleDataError:
# Race
await db.rollback()
# ... mismo response 412 pero con flag race
...
response.headers["ETag"] = f'"{task.version}"'
return {...}
Paso 3 — flow completo:
Funciona. 200 con ETag actualizado, 412 con info detallada cuando es stale.
Paso 4 — sin If-Match:
428 forces al cliente a usar el patrón.
Paso 5 — If-None-Match:
if if_none_match == f'"{task.version}"':
response.status_code = 304
return None
304 sin body. Cliente no transfiere bytes innecesariamente.
Lecciones clave:
- HTTP es rich — usar las primitivas que ya existen es más limpio que reinventar.
- Status codes correctos importan: 412 vs 409, 428 para forzar uso, 304 para cache.
- ETag con comillas obligatorio.
- Pre-check + post-check cubre tanto el caso normal como race condition.
Resumen y siguiente paso
Lo que aprendiste:
ETagheader en responses: identificador opaco del estado actual del recurso. Cliente lo usa en siguientes requests.If-Match: <etag>en requests: precondición HTTP. Server valida y devuelve412 Precondition Failedsi no coincide.428 Precondition Requiredpara forzar al cliente a usarIf-Matchen mutaciones.If-None-Match+304para cache HTTP — complementario aIf-Match.412vs409: 412 para fallo de header HTTP; 409 para version en body o conflicts complejos.- Body version vs
If-Match:If-Matchpara REST típico; body version para bulk, GraphQL, WebSocket. - ETag counter vs hash: counter es default, hash para casos específicos.
Antes de avanzar, deberías poder:
- Implementar
If-Match+412en FastAPI con respuesta rica. - Combinar
If-MatchyIf-None-Matchen el mismo endpoint. - Decidir entre header e body version según el contexto.
- Configurar el cliente para mantener ETags automáticamente.
En la siguiente cápsula cambiamos al otro lado del módulo: schema versioning. ¿Cómo evolucionas tu API sin romper clientes en producción? Vas a aprender qué cambios son backward-compatible (agregar campo opcional, agregar enum value seguro), cuáles son breaking (cambiar tipo, renombrar, remover campo), y los anti-patterns típicos que parecen compatibles pero rompen sutilmente. La cápsula 07 cubre la deprecation strategy formal con headers Deprecation y Sunset.
Recursos
- RFC 7232 — HTTP Conditional Requests — estándar oficial.
- RFC 6585 — Additional HTTP Status Codes (
428) —428 Precondition Required. - MDN —
If-Match— referencia con ejemplos. - MDN —
ETag— referencia y patterns. - GitHub API — Conditional requests — ejemplo real con ETags.
- Stripe API — Idempotency keys — alternativa para casos específicos.
- PayPal API — Optimistic concurrency — caso real con
If-Match.
Cápsula 05 de 08 — Módulo 6 — SQL Patterns for Production APIs Guide