JSON Patch en production : atomicité, idempotence et conflits concurrents
L’essentiel : JSON Patch n'est qu'un format de changement ; l'atomicité, l'idempotence et la concurrence restent à votre charge. Préconditions test, échappement des chemins, retour arrière et coordination par ETag.

L'essentiel : validez d'abord les chemins cibles et les permissions du patch, puis ajoutez une assertion de concurrence avec test ou If-Match ; appliquez le patch sur une copie pour qu'un échec ne laisse rien à moitié écrit.
Le vrai danger d'une requête
PATCHn'est pas d'écrire le mauvais champ. C'est de croire que l'écriture a réussi alors qu'elle a silencieusement écrasé la modification de quelqu'un d'autre. JSON Patch vous donne les outils, mais la conception de la sûreté reste votre affaire.
1. Rappel rapide du jeu d'opérations de la RFC 6902
JSON Patch décrit les changements sous forme de tableau d'opérations, avec le type de média application/json-patch+json. Six opérations s'exécutent dans l'ordre du tableau :
| Opération | Effet | Contrainte clé |
|---|---|---|
add |
Ajouter un membre ou insérer un élément de tableau | Un index de tableau peut être égal à la longueur ; - désigne la fin |
remove |
Supprimer un membre ou un élément | Le chemin cible doit exister |
replace |
Remplacer une valeur existante | Le chemin cible doit exister |
move |
Déplacer un nœud | Impossible de déplacer un nœud dans l'un de ses propres chemins enfants |
copy |
Copier un nœud | Le chemin source doit exister |
test |
Vérifier l'égalité d'une valeur | En cas d'échec, tout le patch n'est pas appliqué |
Beaucoup de gens ne retiennent que les trois premières et oublient que test est ce qui transforme une écriture aveugle en écriture conditionnelle. La sémantique complète des opérations et les frontières des tableaux sont traitées dans JSON Pointer, Patch et Merge Patch.
2. Utiliser test pour transformer un patch en mise à jour conditionnelle
Un patch typique de verrouillage optimiste ne commence pas par une écriture, mais par une assertion de version :
{"op":"test","path":"/version","value":1}
Placez-la dans un patch complet :
[
{ "op": "test", "path": "/version", "value": 1 },
{ "op": "replace", "path": "/version", "value": 2 },
{ "op": "replace", "path": "/status", "value": "paid" }
]
La logique : confirmer que la version du document vaut actuellement 1, puis la passer à 2. Si une autre requête a déjà fait avancer la version à 2, le premier test échoue, tout le patch n'est pas appliqué, et le serveur renvoie un conflit. Cela empêche les mises à jour perdues sans introduire de verrou distribué.
Quelques points de conception :
- Placez
testavant chaque écriture. Si une écriture précédente a déjà pris effet, l'assertion n'a plus de sens ; - Assertissez sur un numéro de version ou un digest généré par le serveur, pas sur un champ ordinaire que le client peut modifier librement ;
- Incrémentez la version à chaque écriture réussie ; sinon deux requêtes utilisant la même ancienne version passent toutes les deux ;
- La
valuedetestdoit avoir exactement le même type JSON que la valeur du document.1et"1"ne sont pas égaux.
3. Échappement des chemins et pollution de prototype
Les règles d'échappement de JSON Pointer sont simples : écrivez ~ comme ~0 et / comme ~1, et remplacez ~ avant /, sinon vous obtenez un double échappement.
Key "a/b" → /a~1b
Key "m~n" → /m~0n
Key "a~/b" → /a~0~1b
Le risque avec les patchs externes n'est généralement pas l'échappement mais les clés dangereuses. En JavaScript, des clés comme __proto__, constructor et prototype peuvent déclencher une pollution de prototype, même via un simple add :
[
{ "op": "add", "path": "/__proto__/polluted", "value": true }
]
Utilisez une liste blanche plutôt qu'une liste noire :
- Autorisez les patchs à modifier uniquement des champs métier, comme
/statusou/items/0/quantity; - Validez chaque
pathetfromcontre une expression régulière ou un préfixe de chemin, en rejetant les niveaux inattendus autres que des indices de tableau ; - Parsez le JSON dans des conteneurs
Object.create(null)ouMappour réduire l'impact d'une chaîne de prototypes polluée ; - Limitez le nombre d'opérations et la taille en octets de chaque
value.
4. Application atomique et retour arrière
La RFC 6902 exige une exécution atomique, mais l'atomicité est une exigence sémantique, pas un comportement par défaut de chaque bibliothèque. Des bibliothèques comme fast-json-patch modifient le document sur place quand mutateDocument vaut true, et un échec peut laisser des changements partiels.
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 };
}
}
Trois recommandations d'implémentation :
- Copie profonde ou clonage structuré d'abord, appliquez sur la copie, et n'échangez la référence qu'après succès ;
- Ne comptez pas sur le retour arrière automatique d'une bibliothèque ; le comportement en cas d'exception diffère selon les implémentations ;
- Validez avant d'exécuter :
validatene vérifie que la structure, tandis que les conflits sémantiques exigent encoretest; l'un ne remplace pas l'autre.
Si un patch couvre plusieurs ressources, par exemple changer ensemble une commande et son inventaire, l'atomicité de JSON Patch ne vaut qu'à l'intérieur d'un seul document. Entre documents, il faut une transaction de base de données ou des actions compensatoires.
5. Idempotence : PATCH n'est pas naturellement idempotent
La sémantique HTTP définit PUT et DELETE comme idempotents, mais PATCH ne l'est pas nécessairement. Répéter le même JSON Patch peut produire des résultats complètement différents :
add /items/-ajoute à la fin du tableau à chaque fois, donc les nouvelles tentatives créent des doublons ;replace /versionpasse la version 1 à 2 et réussit encore lors d'une nouvelle tentative, alors que l'opération a déjà été appliquée ;remove /tempéchoue au second passage parce que le chemin n'existe plus.
Trois approches courantes pour atteindre l'idempotence :
| Approche | Principe | Adaptée à |
|---|---|---|
| Précondition de version | La première opération est test /version, puis incrément |
Ressources avec une version explicite |
| Clé d'idempotence | Le client envoie Idempotency-Key ; le serveur met le résultat en cache |
Paiements, commandes et autres écritures |
| Patch d'état cible | Utiliser replace au lieu de add, pour que le résultat ne dépende que de la valeur finale |
Configuration et champs de statut |
Le pire schéma est un patch qui mélange add et remove en supposant que les nouvelles tentatives sont sûres. Si un patch ajoute ou supprime quoi que ce soit, il doit être associé à une assertion de version ou à une clé d'idempotence.
6. Conflits concurrents : ETag et If-Match
Outre test à l'intérieur du patch, HTTP offre un mécanisme standard : le serveur renvoie un ETag, et le client envoie If-Match lors de la requête suivante.
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 la ressource a changé, le serveur renvoie 412 Precondition Failed, et le client recharge avant de réessayer. If-Match: * signifie « exécuter tant que la ressource existe », ce qui ne convient pas quand il faut empêcher les écrasements.
La différence entre test et If-Match :
If-Matchest vérifié au niveau HTTP avant le parsing du corps, donc c'est moins coûteux ;testest vérifié au niveau sémantique du patch et peut asserter n'importe quel champ sans dépendre du support ETag côté serveur ;- Ils peuvent être combinés, mais n'en implémentez pas un seul en prétendant que le point de terminaison est sûr sous concurrence.
7. Différences entre bibliothèques et sélection
| Implémentation | Langage | Atomicité | Support de test |
Remarques |
|---|---|---|---|---|
fast-json-patch |
JavaScript | Facultative (renvoie un nouveau document par défaut) | Oui | Écosystème large ; attention à mutateDocument |
jsonpatch |
Python | Retour arrière sur exception | Oui | Proche du standard, avec des erreurs claires |
json-patch |
Go | Opère sur une copie | Oui | Souvent utilisé avec JSON Merge Patch |
zjsonpatch |
Java | Oui | Oui | Intégration Jackson pratique |
Ne sélectionnez pas une bibliothèque uniquement sur « prend en charge les six opérations ». Confirmez trois points : si un échec provoque un retour arrière, si test compare strictement, et si un add de tableau hors limites lève une erreur. Les implémentations laxistes font qu'un même patch se comporte différemment selon les environnements.
8. Liste de vérification de sûreté avant lancement
- [ ] Le point de terminaison déclare
application/json-patch+json; - [ ] Les patchs sont appliqués sur une copie, et un échec ne contamine jamais le document original ;
- [ ] Chaque
pathetfromdes patchs externes passe une vérification de liste blanche ; - [ ] Les clés dangereuses comme
__proto__,constructoretprototypesont rejetées ; - [ ] Le nombre d'opérations et la taille d'une valeur unique sont plafonnés ;
- [ ]
testouIf-Matchfournit un contrôle de concurrence ; - [ ] Les patchs sont écrits dans un journal d'audit avec acteur, horodatage et tableau d'opérations complet ;
- [ ] Les frontières de transaction sont explicites quand un patch couvre plusieurs documents.
Un patch est par nature une description complète de « ce qui a changé », ce qui en fait un enregistrement d'audit naturel. Pour les codes d'erreur et l'idempotence au niveau de l'API, poursuivez avec Conception d'API JSON. Si un patch vient de la fusion de deux branches, il vous faut aussi une sémantique de conflit, traitée dans Fusion Git des fichiers JSON.
FAQ
Quel code de statut renvoyer quand une opération test échoue ?
Si le conflit vient d'une assertion de version, 409 Conflict et 412 Precondition Failed sont tous deux défendables. Préférez 412 pour rester cohérent avec If-Match, et incluez la version actuelle dans le corps de la réponse pour que le client puisse réessayer.
Un patch contenant add et remove peut-il être réessayé sans risque ?
Pas directement. add à la fin d'un tableau duplique des données, et remove échoue au second passage car le chemin a disparu. Ajoutez une précondition de version ou une clé d'idempotence pour que les nouvelles tentatives soient reconnues comme la même opération.
JSON Patch peut-il garantir des transactions entre documents ?
Non. L'atomicité de la RFC 6902 s'applique à un seul document JSON. Quand des commandes, des inventaires ou d'autres ressources sont impliqués, utilisez des transactions de base de données, une compensation Saga ou une file de messages pour la cohérence.
Pourquoi un patch ne doit-il jamais écrire dans __proto__ ?
Parce que la plupart des implémentations JavaScript stockent le document dans des objets ordinaires, et écrire __proto__ peut changer la chaîne de prototypes et affecter tous les objets ensuite. Une liste blanche de chemins est la défense la plus fiable ; ne vous fiez pas uniquement au filtrage interne d'une bibliothèque.
Comment rendre une requête PATCH idempotente ?
Trois approches courantes : commencer le patch par test /version et incrémenter la version en cas de succès, faire mettre le résultat en cache par le serveur pour un Idempotency-Key, ou utiliser replace au lieu de add pour que le résultat ne dépende que de la valeur finale. Si un patch ajoute ou supprime quoi que ce soit, il doit être associé à une assertion de version ou à une clé d'idempotence ; il ne peut pas être simplement réessayé.
Faut-il utiliser à la fois test et If-Match ?
Ce n'est pas obligatoire, mais n'en implémenter qu'un seul ne rend pas un point de terminaison sûr sous concurrence. If-Match est vérifié au niveau HTTP avant le parsing du corps, donc c'est moins coûteux ; test est vérifié au niveau sémantique du patch et peut asserter n'importe quel champ sans dépendre du support ETag côté serveur.