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:

  • 412 cuando el cliente usó If-Match y la condición falló — código específico para esa situación.
  • 409 cuando 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:

  1. 428 Precondition Required: si el cliente no envía If-Match, devolver 428 (RFC 6585). Esto fuerza al cliente a usar el patrón.
  2. Quitar comillas del ETag: If-Match: "5" viene literal con comillas. Strip antes de parsear.
  3. 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.
  4. 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 version en 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

CriterioIf-MatchBody version
Estándar HTTP✅ RFC 7232Custom
Caches HTTP✅ CompatibleNo
Tooling estándar✅ Postman, curl, etcMás manual
Bulk updatesDifícil✅ Natural
WebSocket / gRPCNo aplica
GraphQLNo 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:

  1. HTTP es rich — usar las primitivas que ya existen es más limpio que reinventar.
  2. Status codes correctos importan: 412 vs 409, 428 para forzar uso, 304 para cache.
  3. ETag con comillas obligatorio.
  4. Pre-check + post-check cubre tanto el caso normal como race condition.

Resumen y siguiente paso

Lo que aprendiste:

  • ETag header 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 devuelve 412 Precondition Failed si no coincide.
  • 428 Precondition Required para forzar al cliente a usar If-Match en mutaciones.
  • If-None-Match + 304 para cache HTTP — complementario a If-Match.
  • 412 vs 409: 412 para fallo de header HTTP; 409 para version en body o conflicts complejos.
  • Body version vs If-Match: If-Match para 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 + 412 en FastAPI con respuesta rica.
  • Combinar If-Match y If-None-Match en 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

  1. RFC 7232 — HTTP Conditional Requests — estándar oficial.
  2. RFC 6585 — Additional HTTP Status Codes (428)428 Precondition Required.
  3. MDN — If-Match — referencia con ejemplos.
  4. MDN — ETag — referencia y patterns.
  5. GitHub API — Conditional requests — ejemplo real con ETags.
  6. Stripe API — Idempotency keys — alternativa para casos específicos.
  7. PayPal API — Optimistic concurrency — caso real con If-Match.

Cápsula 05 de 08 — Módulo 6 — SQL Patterns for Production APIs Guide