AudioProxy.Expiry (audio_proxy v0.7.0)

Copy Markdown View Source

What exp means once the URL has been parsed: the verdict, and the two clamps that make the verdict stick.

AudioProxy.Options owns the grammar; this module owns the time. It is the one place that reads the clock for expiry, so the verdict and both clamps cannot disagree about what "now" is by more than the microseconds between calls.

The verdict

check/1 is the whole enforcement: :ok while the URL is live, {:error, :expired} after. No clock-skew leeway — a generator that wants a margin adds it to its own timestamps, and leeway here would be a margin every deployment pays whether it wanted one or not. The boundary is now > exp, so the expiring second is still served.

The two clamps

A 410 at the edge of the proxy is theater if the response the live request got hands out a longer-lived copy of itself. Two lifetimes can do that, and both are clamped to remaining/1:

  • The response's Cache-Control. Otherwise a CDN holding a year-long max-age keeps serving the body long after the proxy would refuse the URL.
  • A HIT redirect's presigned TTL. Otherwise the 302 trades an expiring URL for a storage URL that outlives it — the credential leaves the building and the proxy is no longer in the path to say no.

Both clamps are no-ops without exp: remaining/1 answers :infinity, which is above every integer in term order, so min/2 leaves the configured value exactly as it was.

What is deliberately not clamped

The variant metadata written back to the store. Those headers belong to the cached bytes, which are shared by every exp — the whole reason exp is a request option — so storing one requester's remaining lifetime would hand it to every later requester of the same variant. The clamp is a property of the response, applied on the way out, and AudioProxy.VariantCache applies it again to the stored value when it serves a HIT.

Summary

Types

Seconds of life left in the URL, or :infinity when it has no exp.

Functions

The expiry verdict for a parsed options struct.

Rewrites a Cache-Control header so its max-age outlives nothing.

Clamps a credential's time-to-live to the URL's own.

How many seconds of life the URL has left.

Types

remaining()

@type remaining() :: non_neg_integer() | :infinity

Seconds of life left in the URL, or :infinity when it has no exp.

Functions

check(options)

@spec check(AudioProxy.Options.t()) :: :ok | {:error, :expired}

The expiry verdict for a parsed options struct.

:ok for options carrying no exp at all, so callers need no special case.

iex> {:ok, opts} = AudioProxy.Options.parse("exp:1")
iex> AudioProxy.Expiry.check(opts)
{:error, :expired}

clamp_cache_control(header, options)

@spec clamp_cache_control(String.t(), AudioProxy.Options.t()) :: String.t()

Rewrites a Cache-Control header so its max-age outlives nothing.

The header is otherwise untouched — public, immutable and no-transform still describe the bytes correctly for however long they stay fresh, and only the lifetime was ever too long.

iex> {:ok, opts} = AudioProxy.Options.parse("f:mp3")
iex> AudioProxy.Expiry.clamp_cache_control("public, max-age=60", opts)
"public, max-age=60"

clamp_ttl(ttl, options)

Clamps a credential's time-to-live to the URL's own.

Used for the presigned TTL of a HIT redirect. A 0 is possible in the expiring second and is left as 0 rather than floored: a credential that outlives its URL, however briefly, is the thing this exists to prevent.

A zero is a real answer, not a degenerate one, and the caller has to treat it as such. Do not assume the signer refuses it — ExAws.S3.presigned_url/5 signs X-Amz-Expires=0 without complaint, so a backend "failing" is not the safety net it looks like. AudioProxy.VariantCache proxies the bytes rather than redirecting when this returns 0.

remaining(options)

@spec remaining(AudioProxy.Options.t()) :: remaining()

How many seconds of life the URL has left.

:infinity without exp. Never negative: a URL that has already expired has zero seconds left, and every clamp below wants a lifetime it can hand to a header.

iex> AudioProxy.Expiry.remaining(%AudioProxy.Options{})
:infinity