JSON Patch in produzione: atomicità, idempotenza e conflitti concorrenti
In sintesi: JSON Patch è solo un formato di cambiamento; atomicità, idempotenza e concorrenza sono ancora compito tuo. Copre le precondizioni `test`, l'escaping dei percorsi, il rollback e il coordinamento con ETag.

In sintesi: valida prima i percorsi di destinazione e i permessi della patch, poi aggiungi un'asserzione di concorrenza con test o If-Match; applica la patch a una copia così un fallimento non lascia nulla scritto a metà.
Il vero pericolo di una richiesta
PATCHnon è scrivere il campo sbagliato. È credere che la scrittura sia riuscita mentre si sovrascrive silenziosamente la modifica di qualcun altro. JSON Patch ti dà gli strumenti, ma il design della sicurezza resta tuo.
1. Una breve revisione dell'insieme di operazioni RFC 6902
JSON Patch descrive i cambiamenti come un array di operazioni, con il media type application/json-patch+json. Sei operazioni vengono eseguite in ordine di array:
| Operazione | Effetto | Vincolo chiave |
|---|---|---|
add |
Aggiunge un membro o inserisce un elemento di array | Un indice di array può eguagliare la lunghezza; - significa la fine |
remove |
Rimuove un membro o elemento | Il percorso di destinazione deve esistere |
replace |
Sostituisce un valore esistente | Il percorso di destinazione deve esistere |
move |
Sposta un nodo | Non può spostare un nodo nel proprio percorso figlio |
copy |
Copia un nodo | Il percorso sorgente deve esistere |
test |
Asserisce l'uguaglianza dei valori | Se fallisce, l'intera patch non viene applicata |
Molti ricordano solo le prime tre e dimenticano che test è ciò che trasforma una scrittura cieca in una scrittura condizionale. La semantica completa delle operazioni e i confini degli array sono trattati in JSON Pointer, Patch e Merge Patch.
2. Usare test per trasformare una patch in un aggiornamento condizionale
Una tipica patch di optimistic locking non inizia con una scrittura, ma con un'asserzione di versione:
{"op":"test","path":"/version","value":1}
Mettila in una patch completa:
[
{ "op": "test", "path": "/version", "value": 1 },
{ "op": "replace", "path": "/version", "value": 2 },
{ "op": "replace", "path": "/status", "value": "paid" }
]
La logica è: conferma che la versione del documento è attualmente 1, poi cambiala in 2. Se un'altra richiesta ha già portato la versione a 2, il primo test fallisce, l'intera patch non viene applicata, e il server restituisce un conflitto. Questo impedisce gli aggiornamenti persi senza introdurre un lock distribuito.
Alcuni punti di design:
- Metti
testprima di ogni scrittura. Se una scrittura precedente ha già avuto effetto, l'asserzione è priva di significato; - Asserisci su un numero di versione o digest generato dal server, non su un campo ordinario che il client può cambiare liberamente;
- Incrementa la versione a ogni scrittura riuscita; altrimenti due richieste che usano la stessa vecchia versione passano entrambe;
- Il
valueditestdeve avere esattamente lo stesso tipo JSON del valore del documento.1e"1"non sono uguali.
3. Escaping dei percorsi e inquinamento del prototipo
Le regole di escaping di JSON Pointer sono semplici: scrivi ~ come ~0 e / come ~1, e sostituisci ~ prima di /, altrimenti ottieni un doppio escaping.
Key "a/b" → /a~1b
Key "m~n" → /m~0n
Key "a~/b" → /a~0~1b
Il rischio con patch esterne di solito non è l'escaping ma le chiavi pericolose. In JavaScript, chiavi come __proto__, constructor e prototype possono innescare l'inquinamento del prototipo, anche attraverso un normale add:
[
{ "op": "add", "path": "/__proto__/polluted", "value": true }
]
Usa una allowlist invece di una denylist:
- Consenti alle patch di modificare solo campi di business, come
/statuso/items/0/quantity; - Valida ogni
pathefromcontro un'espressione regolare o un prefisso di percorso, rifiutando livelli inattesi diversi dagli indici di array; - Analizza JSON in contenitori
Object.create(null)oMapper ridurre l'impatto di una catena di prototipi inquinata; - Limita il numero di operazioni e la dimensione in byte di ogni
value.
4. Applicazione atomica e rollback
RFC 6902 richiede l'esecuzione atomica, ma l'atomicità è un requisito semantico, non un default in ogni libreria. Librerie come fast-json-patch modificano il documento sul posto quando mutateDocument è true, e un fallimento può lasciare modifiche parziali.
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 };
}
}
Tre raccomandazioni implementative:
- Fai prima una deep copy o uno structured clone, applica sulla copia, e scambia il riferimento solo dopo il successo;
- Non affidarti al rollback automatico di una libreria; il comportamento sulle eccezioni differisce tra implementazioni;
- Valida prima di eseguire:
validatecontrolla solo la struttura, mentre i conflitti semantici necessitano comunque ditest; nessuno sostituisce l'altro.
Se una patch attraversa più risorse, come cambiare un ordine e il suo inventario insieme, l'atomicità di JSON Patch vale solo dentro un singolo documento. Tra documenti necessiti di una transazione di database o di azioni compensative.
5. Idempotenza: PATCH non è naturalmente idempotente
La semantica HTTP definisce PUT e DELETE come idempotenti, ma PATCH non lo è necessariamente. Ripetere lo stesso JSON Patch può produrre risultati completamente diversi:
add /items/-aggiunge alla fine dell'array ogni volta, quindi i retry creano duplicati;replace /versioncambia la versione da 1 a 2 e riesce di nuovo su un retry, anche se l'operazione è già stata applicata;remove /tempfallisce alla seconda esecuzione perché il percorso non esiste più.
Esistono tre modi comuni per ottenere l'idempotenza:
| Approccio | Come funziona | Migliore per |
|---|---|---|
| Precondizione di versione | La prima operazione è test /version, poi incrementa |
Risorse con una versione esplicita |
| Chiave di idempotenza | Il client invia Idempotency-Key; il server memorizza il risultato |
Pagamenti, ordini e altre scritture |
| Patch di stato di destinazione | Usa replace invece di add, così il risultato dipende solo dal valore finale |
Configurazione e campi di stato |
Il pattern peggiore è una patch che mescola add e remove assumendo che i retry siano sicuri. Se una patch aggiunge o elimina qualcosa, deve essere abbinata a un'asserzione di versione o a una chiave di idempotenza.
6. Conflitti concorrenti: ETag e If-Match
Oltre a test dentro la patch, HTTP offre un meccanismo standard: il server restituisce un ETag, e il client invia If-Match sulla richiesta successiva.
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"}]
Se la risorsa è cambiata, il server restituisce 412 Precondition Failed, e il client la recupera di nuovo prima di riprovare. If-Match: * significa "esegui purché la risorsa esista", il che non è adatto quando devi prevenire le sovrascritture.
La differenza tra test e If-Match:
If-Matchviene controllato a livello HTTP prima del parsing del corpo, quindi è più economico;testviene controllato a livello semantico della patch e può asserire qualsiasi campo senza dipendere dal supporto ETag lato server;- Possono essere combinati, ma non implementarne solo uno e dichiarare che l'endpoint è sicuro sotto concorrenza.
7. Differenze tra librerie e selezione
| Implementazione | Linguaggio | Atomicità | Supporto test |
Note |
|---|---|---|---|---|
fast-json-patch |
JavaScript | Opzionale (restituisce un nuovo documento per impostazione predefinita) | Sì | Ecosistema ampio; attenzione a mutateDocument |
jsonpatch |
Python | Fa rollback sulle eccezioni | Sì | Vicino allo standard, con errori chiari |
json-patch |
Go | Opera su una copia | Sì | Spesso usato insieme a JSON Merge Patch |
zjsonpatch |
Java | Sì | Sì | Comoda integrazione con Jackson |
Non selezionare una libreria solo in base a "supporta tutte e sei le operazioni". Conferma tre cose: se un fallimento fa rollback, se test confronta rigorosamente, e se un add di array fuori intervallo solleva un errore. Le implementazioni indulgenti fanno comportare la stessa patch diversamente tra ambienti.
8. Una lista di controllo della sicurezza prima del rilascio
- [ ] L'endpoint dichiara
application/json-patch+json; - [ ] Le patch vengono applicate a una copia, e un fallimento non contamina mai il documento originale;
- [ ] Ogni
pathefromnelle patch esterne passa un controllo di allowlist; - [ ] Chiavi pericolose come
__proto__,constructoreprototypevengono rifiutate; - [ ] Numero di operazioni e dimensione di un singolo valore sono limitati;
- [ ]
testoIf-Matchforniscono il controllo di concorrenza; - [ ] Le patch vengono scritte in un log di audit con attore, timestamp e l'intero array di operazioni;
- [ ] I confini di transazione sono espliciti quando una patch attraversa documenti.
Una patch è intrinsecamente una descrizione completa di "cosa è cambiato", il che la rende una naturale registrazione di audit. Per i codici di errore e l'idempotenza a livello API, continua con Progettazione di API JSON. Se una patch proviene dall'unione di due rami, hai anche bisogno della semantica dei conflitti, trattata in Merge Git per file JSON.
FAQ
Che codice di stato dovrebbe restituire un'operazione test fallita?
Se il conflitto deriva da un'asserzione di versione, sia 409 Conflict sia 412 Precondition Failed sono difendibili. Preferisci 412 per restare coerente con If-Match, e includi la versione corrente nel corpo della risposta così il client può riprovare.
Una patch che contiene add e remove può essere ritentata in sicurezza?
Non direttamente. add alla fine di un array duplica i dati, e remove fallisce alla seconda esecuzione perché il percorso è sparito. Aggiungi una precondizione di versione o una chiave di idempotenza così i retry vengono riconosciuti come la stessa operazione.
JSON Patch può garantire transazioni tra documenti?
No. L'atomicità di RFC 6902 si applica a un singolo documento JSON. Quando sono coinvolti ordini, inventario o altre risorse, usa transazioni di database, compensazione Saga o una coda di messaggi per la consistenza.
Perché una patch non deve mai scrivere su __proto__?
Perché la maggior parte delle implementazioni JavaScript memorizza il documento in oggetti ordinari, scrivere __proto__ può cambiare la catena di prototipi e influenzare ogni oggetto successivo. Una allowlist di percorsi è la difesa più affidabile; non affidarti solo al filtraggio interno di una libreria.
Come rendo idempotente una richiesta PATCH?
Esistono tre approcci comuni: inizia la patch con test /version e incrementa la versione al successo, fai memorizzare al server il risultato per una Idempotency-Key, oppure usa replace invece di add così il risultato dipende solo dal valore finale. Se una patch aggiunge o elimina qualcosa, deve essere abbinata a un'asserzione di versione o a una chiave di idempotenza; non può essere semplicemente ritentata.
Devo usare sia test sia If-Match?
Non devi usarli entrambi, ma implementarne solo uno non rende un endpoint sicuro sotto concorrenza. If-Match viene controllato a livello HTTP prima del parsing del corpo, quindi è più economico; test viene controllato a livello semantico della patch e può asserire qualsiasi campo senza dipendere dal supporto ETag lato server.