Skip to content

ETag et requêtes conditionnelles ​

Référence FT : FT307 (NENE2-FT/etaglog) — Requêtes conditionnelles ETag : If-None-Match→304, If-Modified-Since→304, If-Match→412 périmé / 428 absent, wildcard If-Match: * passe, ETag change après chaque mise à jour, 15 tests PASS.

Les ETags permettent aux clients d'éviter de re-télécharger du contenu inchangé et de détecter l'état périmé avant d'écrire. NENE2 fournit deux helpers pour les patterns les plus courants.

ScénarioEn-têteHelperEn cas de correspondance
GET conditionnelIf-None-MatchConditionalGetHelper304 Not Modified
Écriture conditionnelleIf-MatchConditionalWriteHelperl'écriture se poursuit
Écriture sans en-tête—ConditionalWriteHelper428 Precondition Required
ETag d'écriture périméIf-MatchConditionalWriteHelper412 Precondition Failed

Génération d'ETag ​

Générer un ETag fort depuis le contenu de la ressource sous forme de MD5 entre guillemets doubles :

php
final readonly class Article
{
    public function etag(): string
    {
        // Les guillemets doubles sont requis par la RFC 9110 — sans eux, la comparaison If-None-Match échoue toujours
        return '"' . md5($this->title . $this->body . $this->updatedAt) . '"';
    }
}

Garder la génération d'ETag en un seul endroit (une méthode sur l'entité) pour que changer l'algorithme (ex. vers SHA-256) soit une seule modification.

GET conditionnel — 304 Not Modified ​

php
private function get(ServerRequestInterface $request): ResponseInterface
{
    $article = $this->repo->findById((int) Router::param($request, 'id'));
    if ($article === null) {
        return $this->problems->create($request, 'not-found', 'Article not found.', 404);
    }

    $etag = $article->etag();

    // Retourne une réponse 304 quand If-None-Match correspond à l'ETag courant.
    // Retourne null quand une réponse 200 complète doit être envoyée.
    $notModified = ConditionalGetHelper::check($request, $this->responseFactory, $etag, $article->updatedAt);
    if ($notModified !== null) {
        return $notModified;
    }

    return $this->json->create($this->serialize($article))
        ->withHeader('ETag', $etag)
        ->withHeader('Last-Modified', $article->updatedAt);
}

ConditionalGetHelper::check() évalue deux en-têtes :

  • If-None-Match : correspondance exacte d'ETag → 304
  • If-Modified-Since : comparaison de chaîne $ifModifiedSince >= $lastModified → 304

Toujours inclure la même valeur $etag dans l'appel check() et dans l'appel withHeader('ETag', $etag). Les générer séparément risque une dérive.

Format Last-Modified ​

La vérification If-Modified-Since est une comparaison de chaîne, pas une comparaison de date parsée. Utiliser un format qui trie lexicographiquement — ISO 8601 est recommandé :

php
$now = (new \DateTimeImmutable())->format('Y-m-d\TH:i:s\Z'); // ✅ 2026-05-21T12:00:00Z

Le format standard HTTP Sat, 21 May 2026 12:00:00 GMT trie incorrectement — ne pas l'utiliser avec ce helper.

304 n'a pas de corps ​

La RFC 9110 interdit un corps dans les réponses 304. ConditionalGetHelper retourne un createResponse(304) vide, donc cela est géré correctement tant que vous retournez directement la réponse du helper.

Écriture conditionnelle — If-Match ​

php
private function update(ServerRequestInterface $request): ResponseInterface
{
    $article = $this->repo->findById((int) Router::param($request, 'id'));
    if ($article === null) {
        return $this->problems->create($request, 'not-found', 'Article not found.', 404);
    }

    // Doit appeler AVANT l'écriture — vérifier après est sans signification.
    // Retourne 428 quand If-Match est absent ; 412 quand If-Match est présent mais incorrect.
    // Retourne null quand la précondition passe.
    $preconditionFailed = ConditionalWriteHelper::check($request, $this->problems, $article->etag());
    if ($preconditionFailed !== null) {
        return $preconditionFailed;
    }

    $updated = $this->repo->update($id, $title, $body);

    return $this->json->create($this->serialize($updated))
        ->withHeader('ETag', $updated->etag())
        ->withHeader('Last-Modified', $updated->updatedAt);
}

If-Match: * wildcard ​

Un client peut envoyer If-Match: * pour dire "procéder si la ressource existe". ConditionalWriteHelper passe cela inconditionnellement. L'appelant est responsable de retourner 404 quand la ressource n'existe pas — récupérer l'enregistrement en premier et protéger avec un 404.

Rendre If-Match optionnel ​

Par défaut ($require = true), If-Match manquant retourne 428. Pour autoriser les écritures sans en-tête de précondition :

php
ConditionalWriteHelper::check($request, $this->problems, $article->etag(), require: false);

Assouplir cela uniquement quand le verrouillage optimiste est véritablement optionnel pour la ressource.

Flux client ​

POST /articles            → 201 { id: 1, ... }  ETag: "abc123"
GET  /articles/1          → 200 { id: 1, ... }  ETag: "abc123"

GET  /articles/1          → 304 (pas de corps)
  If-None-Match: "abc123"

PATCH /articles/1         → 200 { ... }  ETag: "def456"
  If-Match: "abc123"
  { title: "Updated" }

PATCH /articles/1         → 412 Precondition Failed
  If-Match: "abc123"       (périmé — le contenu a changé, ETag est maintenant "def456")

PATCH /articles/1         → 428 Precondition Required
  (pas d'en-tête If-Match)

PATCH /articles/1         → 200 { ... }
  If-Match: *              (wildcard — toute version existante)

Toujours inclure ETag dans chaque réponse ​

Retourner ETag (et Last-Modified) sur les réponses POST, GET et PATCH pour que le client ait toujours une valeur fraîche sans aller-retour supplémentaire :

php
return $this->json->create($this->serialize($article), 201)
    ->withHeader('ETag', $article->etag())
    ->withHeader('Last-Modified', $article->updatedAt);

ETag vs Champ de version ​

ETag (en-tête HTTP)Champ de version (corps)
Où vérifiéEn-tête HTTPCorps de requête
GranularitéHash de contenuCompteur entier
Le client doit suivreValeur ETagNuméro de version
Idéal pourCache HTTP + verrouillage optimisteDétection de conflit au niveau API

Ils peuvent être utilisés ensemble : ETag pour la mise en cache HTTP, version pour la détection de conflit au niveau DB (voir optimistic-locking.md).

Liste de contrôle de révision de code ​

  • [ ] La chaîne ETag inclut les guillemets doubles entourants ('"' . md5(...) . '"')
  • [ ] La génération d'ETag est en un seul endroit (méthode d'entité), pas dupliquée entre les gestionnaires
  • [ ] ConditionalGetHelper::check() est appelé avant de construire la réponse 200
  • [ ] La même valeur $etag est passée à check() et à withHeader('ETag', $etag)
  • [ ] ConditionalWriteHelper::check() est appelé avant l'écriture
  • [ ] Le corps de réponse 304 est vide (utiliser directement la réponse du helper)
  • [ ] Les valeurs Last-Modified utilisent le format ISO 8601 (ordre lexicographique requis)
  • [ ] Chaque réponse (201, 200) inclut ETag pour que le client ait toujours une valeur fraîche
  • [ ] Les tests couvrent : 200 sans If-None-Match, 304 en correspondance, 200 sur ETag périmé, 428 sans If-Match, 412 sur If-Match périmé, 200 sur If-Match correct, If-Match: *

Publié sous licence MIT.