Internet Protocols · HTTP

Conditional Requests: Entity Tags, Dates, and Preconditions

How If-None-Match and If-Modified-Since turn a full response into 304, how strong and weak validators differ, and why the precondition order is fixed.

A cache revalidating an entry and a client checking whether its local copy is current face the same problem: they hold a representation and want to know if the server’s copy differs, without transferring it again. The conditional request mechanism answers that with a validator (a token the server can compare cheaply) and a set of precondition fields that make the comparison part of request processing rather than an application-level check.

Two kinds of validator

An entity tag is an opaque string in double quotes, compared byte for byte by the recipient. ETag: "a1b2c3" is strong by default. Prefixing with W/ makes it weak: W/"a1b2c3" asserts that the representations are semantically equivalent, not that they are byte-identical.

The distinction decides which operations are legal. A strong validator guarantees byte-for-byte identity, which is what a byte-range request needs, since the client is going to splice the bytes it fetches into a copy it already holds. A weak validator cannot support that: the client’s copy might differ in whitespace or compression such that the reassembled file is wrong. This is why RFC 9110 requires that a server not serve a range response to a request whose If-Range validator is weak.

A date validator is the Last-Modified field. It is inherently weak, because HTTP dates have one-second resolution, so two modifications within the same second are indistinguishable, and because a server may legitimately report a date earlier than the actual modification time. A representation modified twice in the same second gets one date for both versions, and a client that revalidates with If-Modified-Since will be told nothing changed.

The recommendation in RFC 9110 follows from that: servers should send an entity tag when they can, and should prefer it when both are available. A server that sends only Last-Modified is exposing itself to the one-second aliasing problem, and a server that sends a Last-Modified that does not actually correspond to the modification time is worse, because caches will use it for freshness calculation as well as validation.

Generating a strong tag requires knowing the exact bytes. Common approaches hash the representation, use a version counter bumped on every write, or use a file’s inode and modification timestamp combination. Only the first two are reliably strong. The third is strong only if the server can rule out any change that leaves both unchanged, which is why RFC 9110 discusses the conditions under which a modification date may serve as a strong validator at all.

The precondition fields and their order

Four request fields carry preconditions, and they are evaluated in a fixed order that matters when several appear together.

If-Match compares the request’s tags against the selected representation’s current tag, using strong comparison. If none matches, the server responds 412 Precondition Failed. This is the field that prevents lost updates: a client reads a resource, computes a new version, and sends it back with If-Match: "v7". If someone else wrote v8 in between, the tag no longer matches and the write is refused rather than silently overwriting.

If-None-Match inverts the test: if any tag matches, the condition fails. For GET and HEAD, a failed If-None-Match produces 304 rather than 412, which is the mechanism by which a client with a current copy avoids a body transfer. For every other method, a failed If-None-Match produces 412, which is how If-None-Match: * implements create-if-absent: the condition succeeds only when no representation exists.

If-Modified-Since is evaluated only when If-None-Match is absent. This is not an optimisation; it is a correctness rule. A client sending both is asking the server to prefer the tag, because the tag is the stronger validator. A server that evaluates the date first and ignores the tag will return 304 for a representation whose bytes changed within the same second.

If-Unmodified-Since is the date analogue of If-Match, and it is likewise evaluated only when If-Match is absent. Its typical use is a state-changing request that must not be applied if the resource changed since the client last read it, which is the lost-update problem in date form; ranges use If-Range instead.

If-Range is evaluated separately, and only for range requests. Its value is a single entity tag or date, not a list, and it must be a strong validator to be usable. If it matches, the server honours the Range field and returns 206; if not, it ignores the range and returns 200 with the full representation. The point is atomicity: without If-Range, a client fetching the tail of a file has no way to know whether the bytes it already has are from the same version.

The order is stated in RFC 9110 as: If-Match, then If-Unmodified-Since when If-Match is absent, then If-None-Match, then If-Modified-Since when If-None-Match is absent and the method is GET or HEAD. A server that evaluates them in a different order can produce a 412 where the specification requires a 304, or the reverse, and clients distinguish those.

def evaluate_preconditions(req, current_tag, last_modified, is_get):
    if req.if_match is not None:
        if not any(strong_match(t, current_tag) for t in req.if_match):
            return 412
    elif req.if_unmodified_since is not None:
        if last_modified is None or last_modified > req.if_unmodified_since:
            return 412
    if req.if_none_match is not None:
        if any(weak_match(t, current_tag) for t in req.if_none_match):
            return 304 if is_get else 412
    elif req.if_modified_since is not None and is_get:
        if last_modified is not None and last_modified <= req.if_modified_since:
            return 304
    return None  # proceed with the request

The elif branches are the whole point. Each date field is a fallback for a missing tag field, not an additional test.

Comparison strength by field

If-Match and If-Range use strong comparison: two weak tags never match, and a weak tag never matches a strong one. If-None-Match uses weak comparison, so W/"abc" matches "abc", because the client only needs to know the representation is semantically the same to decide it does not need a new copy. This asymmetry is deliberate and is the reason a server can emit weak tags for compressed variants and still get useful cache revalidation.

If-Modified-Since and If-Unmodified-Since compare dates with the rule that the resource is considered unmodified if its Last-Modified is earlier than or equal to the supplied date. A server whose clock jumps backwards can report a modification as older than the client’s stored date, and the client will then receive 304 for a stale copy. A clock that runs backwards therefore degrades this mechanism silently, which is another argument for tags.

A 304 response must generate any of Content-Location, Date, ETag, Vary, Cache-Control, and Expires that would have been sent in a 200, so the client can update its stored metadata, and it must not carry a body. A client that discards the 304’s fields and keeps its old metadata will keep stale freshness information, and will revalidate more often than necessary.

Where conditionals meet caches and ranges

A shared cache that receives a conditional request from a client can satisfy it from storage. If the stored entry is fresh, the cache evaluates the client’s precondition against the stored validator itself and returns 304 without contacting the origin. If the entry is stale, the cache forwards a conditional request upstream, and the origin’s 304 refreshes the stored entry’s freshness. This is the normal revalidation path, and it is the reason cache freshness and age calculation and validators are designed together: a 304 with an updated Cache-Control resets the clock on an entry whose bytes did not change.

Range requests combine both mechanisms and add a failure mode. A client resuming a download sends Range: bytes=5000- with If-Range: "v1". If the server’s representation is now v2, it must return 200 with the whole body, not 206 with the tail of v2 spliced onto the client’s v1 prefix. A server that honours the range anyway produces a file that is corrupt in a way no checksum in the protocol will catch. The details of range handling are in byte ranges and partial responses.

One more edge case: a conditional GET for a resource that does not exist. If-None-Match: * against a missing resource succeeds, and the server proceeds to return 404. A conditional PUT with If-None-Match: * against a missing resource succeeds and creates it; against an existing one it fails with 412. That pair is the closest HTTP comes to a compare-and-swap primitive, and it is only atomic if the origin server evaluates the condition and performs the write as one operation. A server that checks the tag, then opens a transaction, then writes, has a window in which the guarantee does not hold.

References

  1. RFC 9110: HTTP Semantics — IETF
  2. RFC 9111: HTTP Caching — IETF
  3. RFC 7232: Conditional Requests — IETF