howdy/conditional

Conditional requests: answer 304 Not Modified when the client already has the current version of a response, and 412 Precondition Failed when a write was based on a version that is no longer current.

Describe the version of what a handler is about to send with validators, then let when decide whether to send it:

import howdy/conditional

fn show(ctx: Context) {
  use id <- param.int(ctx, "id")
  case notes.find(repo, id) {
    Error(error) -> service.error_response(ctx, error)
    Ok(note) -> {
      use <- conditional.when(
        ctx,
        conditional.etag(["note", int.to_string(id), int.to_string(note.version)])
          |> conditional.last_modified(note.updated_at),
      )
      controller.json(ctx, note_to_json(note))
    }
  }
}

A browser that saw this note before sends its etag back in if-none-match. While the note is unchanged it gets an empty 304 and the rest of the handler never runs: the JSON is not built and the body is not sent. The database read still happens, since that is what tells the version; cache it with howdy_cache if it is the expensive part.

The response is cache-control: private, no-cache unless you say otherwise with cache_control. Browsers keep it, but ask every time whether it is still current, which is what makes the 304s happen. Shared caches never store it. A page that differs per user must put the user in the etag too, so that one user’s version is never confirmed to another.

Static files from howdy/static use the same rules.

Types

Who may store a response, and for how long it counts as current. Start from private, public or no_store.

pub opaque type CacheControl

What identifies the version of a response: an entity tag, a modification time, or both, and the cache-control to send with it.

pub opaque type Validators

Values

pub fn auto_etag(
  ctx: context.Context(Nil),
  next: fn(context.Context(Nil)) -> response.Response(
    content.Content,
  ),
) -> response.Response(content.Content)

Middleware that gives every 200 text or bytes response to a GET or HEAD a weak etag hashed from its body, and answers 304 when the client already has that body.

howdy.new() |> howdy.middleware(conditional.auto_etag)

The handler still runs in full, so this saves bandwidth, not work: use when where building the response is the cost. Responses that already have an etag, say cache-control: no-store, set a cookie, or stream their body (files, server-sent events, chunked bodies) are left alone.

pub fn cache_control(
  validators: Validators,
  policy: CacheControl,
) -> Validators

Send policy as the cache-control of the response and of its 304, instead of private, no-cache.

pub fn etag(parts: List(String)) -> Validators

A weak entity tag hashed from parts. List whatever changes when the response would change: a record’s id and version or update time, the signed-in user for a per-user page, the language for a translated one.

Weak means the tag vouches for the data, not for the exact bytes, which is right for a response rendered from data: the same note still has the same tag after a gzip middleware compresses it. Use strong_etag to protect writes with if-match.

pub fn http_date(seconds: Int) -> String

Seconds since the Unix epoch as an HTTP date, such as Sun, 06 Nov 1994 08:49:37 GMT.

pub fn immutable(policy: CacheControl) -> CacheControl

The response at this URL never changes, so browsers need not ask even when the user reloads. For fingerprinted assets, with a long max_age.

pub fn is_fresh(
  ctx: context.Context(guarded),
  validators: Validators,
) -> Bool

Whether the client’s copy is current: what when uses to decide on a 304, for handlers that build their own response. Always False for methods other than GET and HEAD.

pub fn last_modified(
  validators: Validators,
  seconds: Int,
) -> Validators

Add a modification time to validators that have an etag. Clients that send if-none-match are judged by the etag alone; older clients and some proxies use the time.

pub fn max_age(
  policy: CacheControl,
  seconds: Int,
) -> CacheControl

Use a stored copy for seconds without asking, instead of asking every time.

pub fn modified_at(seconds: Int) -> Validators

Validators with only a modification time, in seconds since the Unix epoch. HTTP dates carry whole seconds, so two changes within the same second look like one; prefer an etag when that matters.

pub fn must_revalidate(policy: CacheControl) -> CacheControl

Never use the stored copy once max_age has run out, even when the server cannot be reached.

pub fn no_store() -> CacheControl

Nobody may store the response: no-store. For secrets, and anything that must never be shown from a cache, such as a page after sign-out. The other settings are ignored.

pub fn parse_http_date(date: String) -> Result(Int, Nil)

An HTTP date back to seconds since the Unix epoch. Accepts the preferred format and the two obsolete ones HTTP still allows, Sunday, 06-Nov-94 08:49:37 GMT and Sun Nov 6 08:49:37 1994.

pub fn private() -> CacheControl

Only the user’s browser may store the response, and it asks whether its copy is current before each use: private, no-cache. The default for when, and right for anything that differs per user.

pub fn public() -> CacheControl

Shared caches such as a CDN may store the response too: public, no-cache. Only for responses that are the same for everyone.

pub fn quoted_etag(tag: String) -> Validators

An entity tag exactly as given, already quoted, such as "\"v42\"" or "W/\"v42\"". For a version the application already stores, such as a content hash; etag and strong_etag hash parts for you.

pub fn set_cache_control(
  response: response.Response(content.Content),
  policy: CacheControl,
) -> response.Response(content.Content)

Set the cache-control header of a response, for handlers that do not go through when.

pub fn shared_max_age(
  policy: CacheControl,
  seconds: Int,
) -> CacheControl

A different max_age for shared caches: s-maxage. Lets a CDN keep a page for minutes while browsers ask every time.

pub fn stale_while_revalidate(
  policy: CacheControl,
  seconds: Int,
) -> CacheControl

After max_age runs out, keep serving the stored copy for up to seconds more while fetching a new one in the background.

pub fn strong_etag(parts: List(String)) -> Validators

A strong entity tag hashed from parts: a promise that responses with the same tag are byte-for-byte the same. if-match compares only strong tags, so a handler that guards writes with when needs one.

pub fn to_string(policy: CacheControl) -> String

The cache-control header value.

pub fn vary(
  response: response.Response(content.Content),
  headers: List(String),
) -> response.Response(content.Content)

Add header names to the response’s vary, keeping any already there. A cache stores one copy per combination of these request headers, so list every header that changes the response: accept for content negotiation, accept-language for translations.

pub fn when(
  ctx: context.Context(guarded),
  validators: Validators,
  respond: fn() -> response.Response(content.Content),
) -> response.Response(content.Content)

Answer from the validators when the request’s conditions allow it, otherwise run respond and stamp the validators on its response.

  • GET and HEAD get a 304 with no body when if-none-match names the current etag, or, without if-none-match, when if-modified-since is no earlier than the modification time.
  • Any method gets a 412 when if-match names no current strong etag, or, without if-match, when the resource changed after if-unmodified-since. Other methods also get a 412 when if-none-match matches. This is how a PUT is refused when the client edited an old copy; describe the version as it is before the write.

Only a 2xx response to GET or HEAD gets the validators. A response to a write describes the new version, which these validators do not, and an error is not a version at all. Headers the handler set itself are kept.

pub fn without_cache_control(
  validators: Validators,
) -> Validators

Leave cache-control alone: the response keeps whatever the handler set, and a 304 carries none.

✨ Search Document