JSON Toolkit

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.

Avancé~10 min de lecturePublié le 2026-10-31Mis à jour le 2026-10-31
Illustration de l'article JSON Patch en production

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 PATCH n'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 test avant 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 value de test doit avoir exactement le même type JSON que la valeur du document. 1 et "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 :

  1. Autorisez les patchs à modifier uniquement des champs métier, comme /status ou /items/0/quantity ;
  2. Validez chaque path et from contre une expression régulière ou un préfixe de chemin, en rejetant les niveaux inattendus autres que des indices de tableau ;
  3. Parsez le JSON dans des conteneurs Object.create(null) ou Map pour réduire l'impact d'une chaîne de prototypes polluée ;
  4. 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 : validate ne vérifie que la structure, tandis que les conflits sémantiques exigent encore test ; 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 /version passe 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-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 ;
  • 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 path et from des patchs externes passe une vérification de liste blanche ;
  • [ ] Les clés dangereuses comme __proto__, constructor et prototype sont rejetées ;
  • [ ] Le nombre d'opérations et la taille d'une valeur unique sont plafonnés ;
  • [ ] test ou If-Match fournit 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.