Skip to content

HTTP cache headers

The companion crate gpui-query-http turns your server’s HTTP cache headers into a gpui-query CachePolicy. Two pieces: a parser that derives the policy from response headers, and an in-memory HttpCache you can layer over any HTTP client. The server drives cache TTLs, and a relaunch refetch can be a cheap 304 Not Modified.

Terminal window
cargo add gpui-query-http

A fetcher normally returns Result<T, E> and the resource keeps whatever CachePolicy the caller asked for. When the server knows better (it sent Cache-Control: max-age=30, after all), return Result<Fetched<T>, E> from a *_with_policy hook and the server’s policy overrides the caller’s on success.

Parse the response headers with cache_policy_from_headers, hand the result to Fetched::with_policy, and consume the resource with use_query_with_policy:

use gpui_query::core::{CachePolicy, Fetched};
use gpui_query::hook::use_query_with_policy;
use gpui_query_http::cache_policy_from_headers;
async fn fetch_user(id: u64) -> Result<Fetched<User>, MyError> {
let resp = my_client.get(&format!("/users/{id}")).await?;
let policy = cache_policy_from_headers(resp.headers())
.unwrap_or(CachePolicy::NoCache);
Ok(Fetched::with_policy(resp.json::<User>().await?, policy))
}

The resource adopts the server’s TTL for as long as that value is fresh. use_query_with_policy is the server-wins variant of use_query; on refetch, fetch_query_with_policy reapplies the same override.

cache_policy_from_headers(headers: &http::HeaderMap) -> Result<CachePolicy, ParseError> reads Cache-Control per [RFC 9111]. Rules, in priority order:

  1. Cache-Control: no-store or no-cache (bare or any value) → CachePolicy::NoCache. These win immediately: a malformed directive that follows them (no-store, max-age=abc) must not surface as a parse error.
  2. Cache-Control: max-age=N (seconds) → CachePolicy::Ttl { ttl_ms: N * 1000 }. If stale-while-revalidate=M is also present → CachePolicy::StaleWhileRevalidate { ttl_ms: N * 1000, stale_ms: M * 1000 }.
  3. Otherwise → CachePolicy::NoCache (no usable cache directives).

Four specifics:

  • s-maxage (the shared-cache directive) is a fallback: it applies only when max-age is absent, since this is a private cache.
  • Directive names are matched case-insensitively (Max-Age=60 works).
  • Values may be quoted (max-age="600").
  • Multiple Cache-Control headers combine; unknown directives (public, private) are ignored.

The three variants map exactly onto the policies described in Caching:

pub enum CachePolicy {
NoCache,
Ttl { ttl_ms: u64 },
StaleWhileRevalidate { ttl_ms: u64, stale_ms: u64 },
}

HttpCache<B> is a URL-keyed in-memory cache layered over an HttpBackend. Fresh entries short-circuit the network entirely; stale entries are revalidated with conditional headers and a 304 Not Modified re-serves the cached body without a new transfer.

use gpui_query_http::{HttpCache, ReqwestBackend};
let backend = ReqwestBackend::from_client(client);
let cache: HttpCache<ReqwestBackend> = HttpCache::new(backend);
let (body, policy, meta) = cache.fetch("/api/users/42").await?;

HttpCache::fetch(&self, url: &str) -> Result<(Bytes, CachePolicy, Option<CacheMeta>), HttpError> returns the body, the policy in effect for this response, and the cached CacheMeta when the entry is cacheable.

HttpBackend is a trait that abstracts a single conditional GET. The crate ships one optional backend, ReqwestBackend, behind the reqwest cargo feature; reqwest is never a hard dependency. Any other request library can implement HttpBackend and plug into HttpCache::new:

pub trait HttpBackend: Send + Sync {
type Error: std::error::Error + Send + Sync + 'static;
fn fetch(
&self,
url: &str,
conditionals: Conditionals,
) -> impl Future<Output = Result<BackendResponse, Self::Error>> + Send;
}

Dispatch is static (HttpCache<B: HttpBackend>), so there is no Box<dyn> overhead, and the cache never requires tokio.

CacheMeta is the HTTP cache metadata extracted from a response. It holds the validators a backend resends to make a refetch cheap:

pub struct CacheMeta {
pub etag: Option<String>,
pub last_modified: Option<String>,
pub stored_at: std::time::SystemTime,
pub fresh_for: Duration,
pub stale_for: Duration,
}

With the persist feature on gpui-query, attach a serialized CacheMeta to a fetched value with Fetched::with_meta. It lands in PersistedEntry::meta and round-trips back through persistence, so after a relaunch the next fetch can send If-None-Match / If-Modified-Since and turn into a 304 with no body transfer:

use gpui_query::core::Fetched;
let fetched = Fetched::with_policy(data, policy)
.with_meta(serde_json::to_value(&meta).unwrap());

This is what closes the loop with Persistence: the ETag survives a process restart, and hydration re-primes the cache.

  • Caching: how NoCache, Ttl, and StaleWhileRevalidate decide freshness.
  • Persistence: how meta travels through PersistedEntry and back via hydrate.