================================
Static Assets and Cache-Breaking
================================
Browsers cache static assets aggressively. A stylesheet served once with a
long ``Cache-Control`` lifetime may linger in the browser cache for days or
weeks, which is exactly what you want for performance -- right up until you
deploy a new version of the file. At that point the browser happily keeps
serving the stale copy, and your users see broken layouts, old JavaScript,
or missing features until they perform a hard refresh.
The classic fix is *cache-breaking* (also called cache-busting): you change
the URL of the asset whenever its contents change, so the browser treats it
as a brand new resource and re-fetches it. Tet's :mod:`tet.static` module
implements this with a per-deploy token embedded into the static view's URL
prefix, so you can keep aggressive caching headers and still ship updates
that take effect immediately.
This page explains how the cache-breaker works, how to wire it into your
application, and how to emit cache-broken URLs from your templates.
How the cachebreaker works
--------------------------
When you include :mod:`tet.static`, a single **cache-breaker token** is
generated once, at configuration time, and stored on the Pyramid registry as
``config.registry.cachebreaker``. The token is a timestamp -- the current
time in milliseconds since the epoch, zero-padded to twelve digits:
.. code-block:: python
config.registry.cachebreaker = f"{int(time.time() * 1000):012d}"
Because the token is computed once per process startup, it is effectively a
**per-deploy** value: every fresh deployment (which restarts the
application) gets a new token, and every URL generated during that deploy
shares the same token. A typical token looks like ``001718539201234``.
The token is woven into the URL *prefix* of your static view rather than
appended as a query string. When you register a static view with
:func:`add_static_view_with_breaker`, the literal ``{breaker}`` placeholder
in the view name is replaced by the current token. So a view registered as
``static/{breaker}`` actually serves assets from
``static/001718539201234/...``. Generating a URL for an asset with Pyramid's
standard :meth:`~pyramid.request.Request.static_url` then naturally produces
a URL that contains the token, with no extra work in your view code or
templates.
Handling stale and premature URLs
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Embedding the token in the path raises an obvious question: what happens to
URLs that were generated by a *previous* deploy and are still cached in a
user's browser or referenced by an already-loaded page? :mod:`tet.static`
installs a small redirector route alongside the static view to handle these
cases gracefully:
- **Older token** (the request's breaker is less than the current one): the
asset has been re-deployed, so the redirector issues a
``301 Moved Permanently`` to the same asset path under the *current*
token. The browser follows the redirect and picks up the fresh asset.
- **Newer token** (the request's breaker is greater than the current one):
this happens transiently during a rolling deploy, when a page served by a
newer instance points at assets that an older instance has not caught up
with yet. The redirector returns ``503 Service Unavailable`` with a
``Retry-After: 3`` header so the client retries shortly.
- **Matching token but no such asset**: a plain ``404 Not Found``.
You do not have to configure any of this -- registering the view with
:func:`add_static_view_with_breaker` wires up the redirector automatically.
Wiring it into your application
-------------------------------
Pull the module in with ``config.include`` and then register your static
views using the :func:`add_static_view_with_breaker` directive. The
``include`` call adds two directives to the Configurator:
:func:`set_cachebreaker` and :func:`add_static_view_with_breaker`.
.. code-block:: python
from tet.config import application_factory
@application_factory()
def main(config):
config.include("tet.static")
config.add_static_view_with_breaker(
name="static/{breaker}",
path="myapp:static",
)
config.scan()
The ``name`` argument **must** contain the literal ``{breaker}``
placeholder; if it is missing, :func:`add_static_view_with_breaker` raises a
``ValueError``. The ``path`` argument is an ordinary Pyramid asset
specification pointing at the directory that holds your static files. Any
extra keyword arguments are forwarded to the underlying
:meth:`~pyramid.config.Configurator.add_static_view`, so you can pass
options such as ``cache_max_age``:
.. code-block:: python
config.add_static_view_with_breaker(
name="static/{breaker}",
path="myapp:static",
cache_max_age=31536000, # one year -- safe, because the URL changes per deploy
)
Because each deploy mints a new token and therefore a new URL, you can set a
very long ``cache_max_age`` without ever risking a stale asset. That is the
whole point of cache-breaking: maximum caching, zero staleness.
Overriding the token
~~~~~~~~~~~~~~~~~~~~~
The default timestamp token is convenient, but sometimes you want a
deterministic value -- for example, to tie the token to a release tag, a Git
commit hash, or a value injected by your CI pipeline so that several
processes in a cluster all agree on the same token. Use the
:func:`set_cachebreaker` directive **before** registering your static
views:
.. code-block:: python
@application_factory()
def main(config):
config.include("tet.static")
config.set_cachebreaker(config.registry.settings["myapp.release"])
config.add_static_view_with_breaker(
name="static/{breaker}",
path="myapp:static",
)
config.scan()
:func:`set_cachebreaker` simply replaces ``config.registry.cachebreaker``
with the value you supply. Since :func:`add_static_view_with_breaker` reads
the registry at the moment it builds the URL, the override only takes effect
for views registered afterward -- so always set it first.
Using ``static_url`` in templates
---------------------------------
Once the cache-broken static view is registered, you do not need to think
about the token in day-to-day template work. Generate asset URLs the normal
Pyramid way, with :meth:`request.static_url`, passing the same asset
specification style you used for ``path``. The generated URL automatically
includes the current cachebreaker token because it is baked into the view's
URL prefix.
In a Tonnikala template, use ``$`` interpolation -- the expression resolves
the chain of attribute access and the method call as a single unit:
.. code-block:: html
At render time these expand to URLs such as::
/static/001718539201234/style.css
/static/001718539201234/app.js
/static/001718539201234/img/logo.png
After the next deploy the token changes, every emitted URL changes with it,
and browsers fetch the new assets -- while any page still pointing at the old
token gets a ``301`` redirect to the current one.
You can of course use the same call from Python view code or any other
renderer:
.. code-block:: python
@view_config(renderer="myapp:templates/index.tk")
def index(request):
return {
"stylesheet_url": request.static_url("myapp:static/style.css"),
}
.. note::
Always go through :meth:`request.static_url` (or its template
equivalent) rather than hard-coding the ``/static/...`` path yourself.
Hard-coded paths will not contain the cachebreaker token, defeating the
whole mechanism, and will break if the token-bearing prefix ever changes.
See also
--------
- :doc:`templating` -- writing Tonnikala templates and using ``$``
interpolation, including expressions like ``$request.static_url(...)``.
- :doc:`configuration` -- the application factory, ``config.include`` and
registering directives during configuration.