Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

httpcached

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.

How it works

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.

Quick start

go build -o httpcached .
./httpcached            # reads ./config.yaml, listens on server.port

Point 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…).

Configuration (config.yaml)

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" }

Policy fields

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

Behaviour notes

  • Only GET is 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_revalidate removes the queueing entirely by answering from disk.
  • A stale answer carries X-Cache: STALE and Warning: 110 "Response is Stale".
  • A refresh failure with stale_if_error also 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-Encoding is 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.

Observability

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

Limitations

  • 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 Vary response header is stored but not applied at lookup time; declare vary in the policy for anything per-caller (or set cache: 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.

Development

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]' -v

TEST-REPORT-singleflight.md records the measurements behind the design above.

About

Cache HTTP data simply

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages