JSON Patch en producción: atomicidad, idempotencia y conflictos concurrentes
En resumen: JSON Patch es solo un formato de cambio; la atomicidad, la idempotencia y la concurrencia siguen siendo tu responsabilidad. Cubre precondiciones con test, escape de rutas, rollback y coordinación con ETag.

En resumen: valida primero las rutas objetivo y los permisos del parche, luego añade una comprobación de concurrencia con test o If-Match; aplica el parche sobre una copia para que un fallo no deje nada a medio escribir.
El peligro real de una petición
PATCHno es escribir el campo equivocado. Es creer que la escritura tuvo éxito mientras se sobrescribe en silencio el cambio de otra persona. JSON Patch te da las herramientas, pero el diseño de seguridad sigue siendo tuyo.
1. Repaso rápido del conjunto de operaciones de RFC 6902
JSON Patch describe cambios como un array de operaciones, con el tipo de medio application/json-patch+json. Seis operaciones se ejecutan en el orden del array:
| Operación | Efecto | Restricción clave |
|---|---|---|
add |
Añadir un miembro o insertar un elemento de array | Un índice de array puede ser igual a la longitud; - significa el final |
remove |
Eliminar un miembro o elemento | La ruta objetivo debe existir |
replace |
Reemplazar un valor existente | La ruta objetivo debe existir |
move |
Mover un nodo | No se puede mover un nodo a una ruta hija de sí mismo |
copy |
Copiar un nodo | La ruta de origen debe existir |
test |
Comprobar igualdad de valores | Si falla, el parche completo no se aplica |
Mucha gente solo recuerda las tres primeras y olvida que test es lo que convierte una escritura a ciegas en una escritura condicional. La semántica completa de las operaciones y los límites de los arrays se tratan en JSON Pointer, Patch y Merge Patch.
2. Usar test para convertir un parche en una actualización condicional
Un parche típico de bloqueo optimista no empieza con una escritura, sino con una comprobación de versión:
{"op":"test","path":"/version","value":1}
Inclúyelo en un parche completo:
[
{ "op": "test", "path": "/version", "value": 1 },
{ "op": "replace", "path": "/version", "value": 2 },
{ "op": "replace", "path": "/status", "value": "paid" }
]
La lógica es: confirmar que la versión del documento es actualmente 1 y luego cambiarla a 2. Si otra petición ya ha avanzado la versión a 2, el primer test falla, el parche completo no se aplica y el servidor devuelve un conflicto. Esto evita actualizaciones perdidas sin introducir un bloqueo distribuido.
Algunos puntos de diseño:
- Coloca
testantes de cada escritura. Si una escritura anterior ya se aplicó, la comprobación no tiene sentido; - Comprueba un número de versión o un digest generado por el servidor, no un campo normal que el cliente pueda cambiar libremente;
- Incrementa la versión en cada escritura correcta; de lo contrario, dos peticiones que usen la misma versión antigua pasarán ambas;
- El
valuedetestdebe tener exactamente el mismo tipo JSON que el valor del documento.1y"1"no son iguales.
3. Escape de rutas y contaminación de prototipos
Las reglas de escape de JSON Pointer son simples: escribe ~ como ~0 y / como ~1, y reemplaza ~ antes que /, de lo contrario obtienes un doble escape.
Key "a/b" → /a~1b
Key "m~n" → /m~0n
Key "a~/b" → /a~0~1b
El riesgo con parches externos normalmente no es el escape, sino las claves peligrosas. En JavaScript, claves como __proto__, constructor y prototype pueden desencadenar contaminación de prototipos, incluso mediante un add normal:
[
{ "op": "add", "path": "/__proto__/polluted", "value": true }
]
Usa una lista de permitidos en lugar de una lista de bloqueados:
- Permite que los parches modifiquen solo campos de negocio, como
/statuso/items/0/quantity; - Valida cada
pathyfromcontra una expresión regular o un prefijo de ruta, rechazando niveles inesperados distintos de los índices de array; - Procesa el JSON en contenedores
Object.create(null)oMappara reducir el impacto de una cadena de prototipos contaminada; - Limita el número de operaciones y el tamaño en bytes de cada
value.
4. Aplicación atómica y rollback
RFC 6902 exige ejecución atómica, pero la atomicidad es un requisito semántico, no un valor por defecto en todas las bibliotecas. Bibliotecas como fast-json-patch modifican el documento en su lugar cuando mutateDocument es true, y un fallo puede dejar cambios parciales.
import { applyPatch, validate } from 'fast-json-patch';
/**
* Apply a patch immutably and return the original document on failure.
* @param document The original document, which callers must not mutate
* @param patch The JSON Patch operation array to apply
* @returns The result, including a success flag and the new document
*/
function applySafely(document: unknown, patch: unknown[]) {
const errors = validate(patch, document);
if (errors) {
return { ok: false, document };
}
try {
const result = applyPatch(document, patch, false, false);
return { ok: true, document: result.newDocument };
} catch {
return { ok: false, document };
}
}
Tres recomendaciones de implementación:
- Copia en profundidad o
structured-cloneprimero, aplica sobre la copia e intercambia la referencia solo tras el éxito; - No dependas del rollback automático de una biblioteca; el comportamiento ante excepciones varía entre implementaciones;
- Valida antes de ejecutar:
validatesolo comprueba la estructura, mientras que los conflictos semánticos siguen requiriendotest; ninguno sustituye al otro.
Si un parche abarca varios recursos, como cambiar un pedido y su inventario a la vez, la atomicidad de JSON Patch solo se cumple dentro de un único documento. Entre documentos necesitas una transacción de base de datos o acciones compensatorias.
5. Idempotencia: PATCH no es idempotente por naturaleza
La semántica HTTP define PUT y DELETE como idempotentes, pero PATCH no tiene por qué serlo. Repetir el mismo JSON Patch puede producir resultados completamente distintos:
add /items/-añade al final del array cada vez, así que los reintentos crean duplicados;replace /versioncambia la versión 1 a 2 y vuelve a tener éxito en un reintento, aunque la operación ya se haya aplicado;remove /tempfalla en la segunda ejecución porque la ruta ya no existe.
Hay tres formas habituales de lograr idempotencia:
| Enfoque | Cómo funciona | Ideal para |
|---|---|---|
| Precondición de versión | La primera operación es test /version y luego se incrementa |
Recursos con una versión explícita |
| Clave de idempotencia | El cliente envía Idempotency-Key; el servidor cachea el resultado |
Pagos, pedidos y otras escrituras |
| Parche de estado objetivo | Usar replace en lugar de add, de modo que el resultado dependa solo del valor final |
Campos de configuración y estado |
El peor patrón es un parche que mezcla add y remove mientras asume que los reintentos son seguros. Si un parche añade o elimina algo, debe ir acompañado de una comprobación de versión o de una clave de idempotencia.
6. Conflictos concurrentes: ETag e If-Match
Además del test dentro del parche, HTTP ofrece un mecanismo estándar: el servidor devuelve un ETag y el cliente envía If-Match en la siguiente petición.
GET /orders/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json
[{"op":"replace","path":"/status","value":"paid"}]
Si el recurso ha cambiado, el servidor devuelve 412 Precondition Failed y el cliente vuelve a obtenerlo antes de reintentar. If-Match: * significa "ejecutar siempre que el recurso exista", lo que no es adecuado cuando necesitas evitar sobrescrituras.
La diferencia entre test e If-Match:
If-Matchse comprueba en la capa HTTP antes de analizar el cuerpo, así que es más barato;testse comprueba en la capa semántica del parche y puede comprobar cualquier campo sin depender del soporte de ETag del servidor;- Se pueden combinar, pero no implementes solo uno de ellos y afirmes que el endpoint es seguro bajo concurrencia.
7. Diferencias entre bibliotecas y selección
| Implementación | Lenguaje | Atomicidad | Compatibilidad con test |
Notas |
|---|---|---|---|---|
fast-json-patch |
JavaScript | Opcional (devuelve un nuevo documento por defecto) | Sí | Ecosistema amplio; cuidado con mutateDocument |
jsonpatch |
Python | Revierte ante excepciones | Sí | Cercana al estándar, con errores claros |
json-patch |
Go | Opera sobre una copia | Sí | A menudo se usa junto a JSON Merge Patch |
zjsonpatch |
Java | Sí | Sí | Integración cómoda con Jackson |
No elijas una biblioteca solo por "compatibilidad con las seis operaciones". Confirma tres cosas: si un fallo revierte, si test compara de forma estricta y si un add con índice de array fuera de rango lanza un error. Las implementaciones laxas hacen que el mismo parche se comporte de forma distinta según el entorno.
8. Lista de comprobación antes del lanzamiento
- [ ] El endpoint declara
application/json-patch+json; - [ ] Los parches se aplican sobre una copia y un fallo nunca contamina el documento original;
- [ ] Cada
pathyfromde los parches externos pasa una comprobación de lista de permitidos; - [ ] Se rechazan claves peligrosas como
__proto__,constructoryprototype; - [ ] Se limitan el número de operaciones y el tamaño de un valor individual;
- [ ]
testoIf-Matchproporcionan control de concurrencia; - [ ] Los parches se registran en un log de auditoría con actor, marca de tiempo y el array completo de operaciones;
- [ ] Los límites de transacción son explícitos cuando un parche abarca varios documentos.
Un parche es, por naturaleza, una descripción completa de "qué cambió", lo que lo convierte en un registro de auditoría natural. Para los códigos de error y la idempotencia en la capa de API, continúa con Diseño de API JSON. Si un parche proviene de fusionar dos ramas, también necesitas semántica de conflictos, tratada en Fusión de Git para archivos JSON.
Preguntas frecuentes
¿Qué código de estado debería devolver una operación test fallida?
Si el conflicto proviene de una comprobación de versión, tanto 409 Conflict como 412 Precondition Failed son defendibles. Prefiere 412 para mantener la coherencia con If-Match, e incluye la versión actual en el cuerpo de la respuesta para que el cliente pueda reintentar.
¿Se puede reintentar con seguridad un parche que contiene add y remove?
No directamente. add al final de un array duplica datos, y remove falla en la segunda ejecución porque la ruta ya no existe. Añade una precondición de versión o una clave de idempotencia para que los reintentos se reconozcan como la misma operación.
¿Puede JSON Patch garantizar transacciones entre documentos?
No. La atomicidad de RFC 6902 se aplica a un único documento JSON. Cuando intervienen pedidos, inventario u otros recursos, usa transacciones de base de datos, compensación Saga o una cola de mensajes para la consistencia.
¿Por qué un parche nunca debe escribir en __proto__?
Porque la mayoría de implementaciones de JavaScript almacenan el documento en objetos normales, y escribir __proto__ puede cambiar la cadena de prototipos y afectar a todos los objetos posteriores. Una lista de permitidos de rutas es la defensa más fiable; no confíes solo en el filtrado interno de una biblioteca.
¿Cómo hago idempotente una petición PATCH?
Hay tres enfoques habituales: empezar el parche con test /version e incrementar la versión al tener éxito, hacer que el servidor cachee el resultado para una Idempotency-Key, o usar replace en lugar de add para que el resultado dependa solo del valor final. Si un parche añade o elimina algo, debe ir acompañado de una comprobación de versión o de una clave de idempotencia; no se puede reintentar sin más.
¿Debería usar test e If-Match a la vez?
No tienes que usar ambos, pero implementar solo uno de ellos no hace que un endpoint sea seguro bajo concurrencia. If-Match se comprueba en la capa HTTP antes de analizar el cuerpo, así que es más barato; test se comprueba en la capa semántica del parche y puede comprobar cualquier campo sin depender del soporte de ETag del servidor.