A local HTTP reverse-proxy cache for file-style resources — Linux package repos (apt / yum / apk), the npm registry, PyPI, container blobs, and any other resource served over plain HTTP. It cuts load on upstream mirrors, eliminates wasted re-downloads, and dramatically speeds up repeated/CI builds.
Built on GoFiber v3 (fasthttp), zap and viper.
client ──HTTP──> GoFiber (fasthttp) ──> cache handler
1. Host selects a site; longest-prefix path match selects an upstream
2. Rules (ext / path / host / upstream) resolve a Policy (ttl, min_hits, ...)
3. lookup:
fresh hit → stream from disk X-Cache: HIT
stale (within the → stream the old copy at once, X-Cache: STALE + Warning: 110
stale_while_ one background refresh
revalidate budget)
stale (no budget) → conditional GET (304/200) X-Cache: REVALIDATED / MISS
remembered 404 → answered locally X-Cache: NEGATIVE
cold (< min_hits) → pass-through, not cached X-Cache: PASS
admitted miss → single-flight fetch + tee X-Cache: MISS
Concurrency: every origin round trip is gated per cache key. N clients hitting one cold or expired object cause one upstream request — including the conditional GET of a revalidation — and share its body as it streams.
Storage: bodies live in sharded blob files (blobs/ab/cd/<sha256>); metadata is
a JSON .meta sidecar next to each blob. Writes are atomic (temp file → fsync →
rename); a crash can only leave an orphan blob, which is cleaned on the next
startup scan.
go build -o httpcached .
./httpcached # reads ./config.yaml, listens on server.portPoint a client at it by Host + path prefix. With the sample config:
# apt: /etc/apt/sources.list ->
# deb http://mirror.local:8000/ubuntu jammy main
# (resolve mirror.local to the cache host, or use the cache IP directly)
# npm:
npm config set registry http://mirror.local:8000/npm/
# pip:
pip install --index-url http://mirror.local:8000/pypi/simple some-pkg
# raw check:
curl -H 'Host: mirror.local' http://127.0.0.1:8000/ubuntu/dists/jammy/InRelease -D-Every response carries an X-Cache: HIT|MISS|REVALIDATED|PASS|STALE header.
GET /_cache/stats returns JSON counters (hits, misses, hit ratio, disk usage…).
Send SIGHUP to hot-reload the cache section (rules / sites / upstream pool)
without dropping the cache.
cache:
storage:
dir: "./cache_data" # blobs/ + tmp/ live here
max_size: "50GB" # soft cap; LFU/LRU eviction above it (0 = unlimited)
max_age: "720h" # hard retention cap
caretaker_interval: "5m"
upstream: # shared origin connection pool / timeouts
max_conns_per_host: 256
max_idle_conns_per_host: 64
idle_conn_timeout: "90s"
dial_timeout: "10s"
response_header_timeout: "30s"
follow_redirects: true
# user_agent: "httpcached/1.0.0" # override UA sent to origins (empty = forward client UA)
admission:
window_size: 100000 # max keys tracked for popularity
default_min_hits: 2
defaults: # fall-back policy; rules inherit unset fields
cache: true
ttl: "24h"
revalidate: true
min_hits: 2
stale_if_error: true
stale_while_revalidate: "30s" # serve the old copy while one refresh runs
negative_ttl: "10s" # remember 404/410 so misses stop punching through
# vary: ["Authorization"] # headers that fork the cache key
rules: # first match wins
- name: immutable-artifacts
match: { ext: [deb, rpm, apk, whl, tgz, jar] }
policy: { immutable: true, ttl: "720h", min_hits: 1 }
- name: repo-metadata
match: { path_regex: '(InRelease|Packages(\.\w+)?|repomd\.xml|.*\.json)$' }
policy: { ttl: "5m", revalidate: true, min_hits: 1 }
- name: no-cache-dynamic
match: { path_regex: '(/token|/v2/auth)' }
policy: { cache: false }
sites: # Host -> site; path prefix -> upstream
- hosts: ["mirror.local", "127.0.0.1"]
routes:
- { path: "/ubuntu", upstream: "http://archive.ubuntu.com/ubuntu" }
- { path: "/npm", upstream: "https://registry.npmjs.org" }
- { path: "/pypi", upstream: "https://files.pythonhosted.org" }
- hosts: ["npm.local"]
routes:
- { path: "/", upstream: "https://registry.npmjs.org" }| field | meaning |
|---|---|
cache |
false ⇒ never cache (pure pass-through) |
ttl |
freshness lifetime |
immutable |
never revalidate; serve until evicted |
revalidate |
when stale, do a conditional GET (If-None-Match / If-Modified-Since) |
min_hits |
requests before an object is admitted to disk (1 = cache immediately) |
respect_origin |
fold the origin's Cache-Control / Expires into the TTL |
stale_if_error |
serve a stale copy if the origin fails — unreachable or answering 5xx |
stale_while_revalidate |
grace period past the TTL during which the old copy is served immediately while one background refresh runs (0 = staleness blocks the client) |
negative_ttl |
how long a 404/410 from the origin is remembered, so repeat lookups for a missing object don't pass through (0 = disabled) |
vary |
request header names that take part in the cache key and the single-flight merge, e.g. ["Authorization"]. Unset = one object per URL shared by every client |
ignore_query |
drop the query string from the cache key |
max_object_size |
objects larger than this are streamed through but not cached |
- Only
GETis cached; other methods and no-cache policies are proxied verbatim. - Per-key request coalescing. An expired hot key costs one origin round trip,
not one per client: clients that arrive during a refresh either attach to the
running download or queue behind the key's gate.
stale_while_revalidateremoves the queueing entirely by answering from disk. - A stale answer carries
X-Cache: STALEandWarning: 110 "Response is Stale". - A refresh failure with
stale_if_erroralso pauses revalidation for that key for a few seconds, so a dead origin is not re-asked once per client. - The fill is detached from the client: a browser that gives up halfway still lands the object, and other clients keep streaming.
Accept-Encodingis not forwarded, so cached bodies are identity-encoded and safe to replay to any client.- Range requests are served from a fully-cached object (
206); a range for an uncached object is passed through (the cache is never populated from a partial response). - Hop-by-hop headers are stripped both ways. Proxy-chain headers from a front
nginx/CDN (
X-Forwarded-*,Forwarded,X-Real-IP,Via) are also stripped before contacting the origin — forwarding them makes some CDNs "correct" the scheme with a redirect loop. - Origin fetch failures are logged (with the URL and, for redirect loops, the full hop chain) in addition to the 502 body returned to the client.
GET /_cache/stats reports both sides, because hit_ratio counts a revalidated
object as a hit and cannot reveal a breakdown on its own:
| field | meaning |
|---|---|
origin_requests |
every request that reached an origin (fetch, conditional GET, pass-through) |
conditional_requests |
of those, the ones that carried a validator |
coalesced_joins |
clients served by attaching to a download somebody else started |
background_refreshes |
stale-while-revalidate refreshes started off the request path |
origin_requests_per_client_request |
~0 means the cache is absorbing the load; ~1 means it is being bypassed |
- No Docker Registry v2 token-auth handshake (generic HTTP caching only — works for repos/registries that don't require a per-request token).
- The origin's own
Varyresponse header is stored but not applied at lookup time; declarevaryin the policy for anything per-caller (or setcache: false). - A Range request does not attach to an in-flight full-object download; it issues its own origin request. Segmented downloaders multiply upstream load.
- Multi-range requests are served as their first range.
- Admission uses an approximate LRU frequency filter (good enough to separate cold from hot).
- No purge/ban API: after the origin changes an object, the stale copy can live until its TTL expires.
go test ./... -race # unit + integration (single-flight, admission, range, persistence)
# Documented-behaviour probe: turns every measured deviation (breakdown fan-out,
# stale-serving, negative-cache leak) into a failure and runs the -race hunt.
SF_STRICT=1 go test ./internal/cache/ -race -v
# Concurrency/breakdown scenarios (S1..S32) with per-scenario origin counters:
go test ./internal/cache/ -run 'TestS[0-9]' -vTEST-REPORT-singleflight.md records the measurements behind the design above.