Cache Replies and Prevent Lost Updates
The catalogue changes rarely, and browsers should not download it on every visit. Two members of
staff edit one order, and the second save must not silently undo the first. Three things serve
these needs: cache-control tells clients how long a reply may be kept, etag and if-none-match let a client ask “has this changed?” without downloading it again, and VERSION with if-match lets a client write only if nothing changed since it read.
§Declaring how long a reply is kept
cache:control sets the cache-control header of successful replies to GET and HEAD. The
catalogue is a component of its own, store.catalog, whose products have a title and a price:
# components/store.catalog/manifest.toa.yaml
exposition:
/:
anonymous: true
io:output: [id, title, price]
cache:control: max-age=300
GET: enumerate HTTP/1.1 200 OK
cache-control: max-age=300 The value is a standard cache-control value; the gateway does not interpret max-age or keep
replies itself. It tells browsers and whatever stands in front of the application what they may
do.
Like any directive it applies to everything under the node it is written on, and a nested declaration replaces it:
exposition:
/:
cache:control: max-age=300
GET: enumerate
/:id:
cache:control: max-age=30
GET: observe Replies to other methods are not affected: a POST on a node with cache:control gets no cache-control from it.
§What happens without a declaration
- A reply to an anonymous request carries no
cache-control. - A reply to an authenticated request carries
cache-control: no-store.
So nothing private is stored anywhere unless a route says it may be.
§Authenticated requests
A reply to an authenticated request may hold private data. cache:control therefore adjusts its
value when the request is authenticated:
| Declared | Sent to an authenticated request | Why |
|---|---|---|
no private | private is added | A shared cache must not store one identity’s reply. |
private, declared or added | vary: authorization is added | A browser must not reuse the reply after signing in as someone else. |
public without no-cache | no-cache is added | A shared cache must revalidate, so that tokens are not served from it. |
# components/store.orders/manifest.toa.yaml
exposition:
/of/:customer:
auth:id: customer
io:output: [id, status, total]
cache:control: max-age=60
GET: enumerate The orders are those of store.orders, the component Create a Component writes, and auth:id admits the customer
the path names.
HTTP/1.1 200 OK
cache-control: private, max-age=60
vary: authorization The same route answered to an anonymous request, where its access rule admits one, carries the
value as declared: cache-control: max-age=60, without vary: authorization.
Two consequences to keep in mind:
- Because a private reply varies on
authorization, a browser’s copy is dropped whenever the client’s token is replaced. The usefulmax-ageof a private resource is at most the lifetime of a token. - A reply that itself issues a token carries
no-store, whatever the route declares.
Tokens and their lifetime are described in Tokens.
§An exact value
cache:exact sets cache-control to exactly what is written, with none of the adjustments
above, and for any method. Here the route is one of a component that keeps the shop’s banners,
open to staff:
exposition:
/banners:
auth:role: staff
io:output: true
cache:exact: public, max-age=3600
GET: enumerate HTTP/1.1 200 OK
cache-control: public, max-age=3600 With cache:exact the safety is yours. Use it for replies that are the same for every
caller although the route requires a credential.
Its other use is to forbid storing the reply of a method that is not GET, such as that of an
operation issue which answers a receipt:
exposition:
/receipts:
cache:exact: no-store
POST: issue cache:control | cache:exact | |
|---|---|---|
| Methods | GET, HEAD | any |
| Adjusted for authenticated requests | yes | no |
§Asking whether a reply changed
Every reply to a GET or HEAD carries an etag: a hash of the body the client receives.
GET /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1 HTTP/1.1 200 OK
etag: "9b2cf535f27731c974343645a3985328"
{"id":"4c4759e6f9c74da989d64511df42d6f4","status":"pending","total":4200} A client that kept the reply sends the tag back in if-none-match. If the body would be the
same, it gets no body:
GET /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
if-none-match: "9b2cf535f27731c974343645a3985328" HTTP/1.1 304 Not Modified
etag: "9b2cf535f27731c974343645a3985328" Nothing is declared for this. Browsers do it on their own for replies they have stored.
- The tag is of the body as this client receives it: after
io:output, in the negotiated format. Two routes over one entity have different tags. - A weak tag (
W/"…") inif-none-matchmatches as a strong one does. HEADreturns the tag itsGETwould, without the body.- A
304saves the transfer, not the work: the operation runs to produce the body the tag is computed from.
A reply carries no etag when a client could never send it back:
- a reply to a method other than
GETandHEAD; - a reply declared
no-store; - a reply that is not the operation’s — an error, an exception.
A stored file’s tag is the checksum of its content, see Upload and Download Files.
§Prevent lost updates
Two members of staff open the same order. Both edit it. Without a precaution the second save silently undoes the first.
Every entity carries a VERSION that grows with each change. A client that sends the version
it read in the if-match header has its write applied only if that is still the current
version.
VERSION is one of the system properties of the orders store.orders keeps. The write here is amend, an operation that replaces the items of an
order; it forwards to the inherited transit:
# components/store.orders/manifest.toa.yaml
operations:
# …
amend:
forward: transit
concurrency: retry
query: true
input:
type: object
properties:
items: { type: array }
required: [items] Expose the version to clients, on the /:id route Expose It over HTTP declares:
# components/store.orders/manifest.toa.yaml
exposition:
/:
anonymous: true
io:output: true
GET: enumerate
POST: create
/:id:
io:output: [id, items, total, status, VERSION]
GET: observe
PUT: amend $ npm run restart GET /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
accept: application/yaml HTTP/1.1 200 OK
id: 4c4759e6f9c74da989d64511df42d6f4
status: pending
total: 4200
VERSION: 3 The client writes with the version it holds, in quotes:
PUT /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
if-match: "3"
content-type: application/yaml
items:
- product: kettle
quantity: 2 HTTP/1.1 200 OK If the order has changed in the meantime, the write is refused and nothing is changed:
HTTP/1.1 412 Precondition Failed The client reads the order again, shows the user what changed, and retries with the new version.
The value in if-match becomes query.version of the call
(Call Operations and Select State); the operation does nothing for it.
if-match takes the VERSION, not the etag. The etag is a hash of a representation and
says nothing about the entity’s version. Sending it in if-match is refused:
HTTP/1.1 400 Bad Request
"Invalid ETag" A weak form, if-match: W/"3", is accepted and means the same.
As a scenario, with a stale version; its steps are those of Write a Test, and it is run with npm run features while sys is
running (npm run sys):
# features/orders.feature
Scenario: A write with a stale version is refused
When the request is sent:
"""
POST /store/orders/ HTTP/1.1
content-type: application/yaml
accept: application/yaml
customer: b70a7dbca6b14a2eaac8a9eb4b2ff4db
items: []
total: 4200
"""
Then the response is received:
"""
201 Created
id: ${{ order.id }}
"""
When the request is sent:
"""
GET /store/orders/${{ order.id }}/ HTTP/1.1
accept: application/yaml
"""
Then the response is received:
"""
200 OK
VERSION: ${{ order.version }}
"""
When the request is sent:
"""
PUT /store/orders/${{ order.id }}/ HTTP/1.1
if-match: "${{ order.version }}"
content-type: application/yaml
items:
- product: kettle
quantity: 2
"""
Then the response is received:
"""
200 OK
"""
When the request is sent:
"""
PUT /store/orders/${{ order.id }}/ HTTP/1.1
if-match: "${{ order.version }}"
content-type: application/yaml
items:
- product: kettle
quantity: 3
"""
Then the response is received:
"""
412 Precondition Failed
""" §Making it mandatory
if-match is optional unless the route requires it. To make every write conditional:
# components/store.orders/manifest.toa.yaml
exposition:
/:
# …
/:id:
io:output: [id, items, total, status, VERSION]
GET: observe
PUT:
endpoint: amend
require:header: if-match A PUT without the header is now 400, see Requiring headers.
§Where it does not apply
if-match is a version only for an operation that takes a query. On a route to a computation
or an effect it is an ordinary header, which the route may read with map:headers if it has a use for it.
§A checklist
- Public, rarely changing data:
anonymousaccess andcache:control: max-age=…. - Private data worth keeping in the browser:
cache:control: max-age=…; it is madeprivatefor you. - Identical for every signed-in user:
cache:exact: public, max-age=…. - Must never be stored:
cache:exact: no-store. - Clients that poll: nothing to declare; they send
if-none-match. - Entities that several people edit:
VERSIONinio:output, andrequire:header: if-match.