Skip to content

Add stale_ttl to serve stale cached keys when an upstream fetch fails #11

Description

@dolph

Follow-up to #2 / PR #10. Discussed as out-of-scope there; filing here.

Summary

Add a global stale_ttl config option that lets ussher serve stale cached keys for a bounded window beyond cache_ttl when the upstream fetch fails. The cache file format already records each entry's write time (PR #8), so this is a pure logic addition in the HTTP client — no on-disk schema change.

Motivation

After PR #10, a transient upstream failure (DNS hiccup, GitHub 503, TLS handshake error, network blip) no longer crashes ussher — it logs and returns an empty slice. That's the right correctness fix, but it has an availability cost: every login during the outage falls through to "no keys", which sshd treats as a denial. For a tool whose job is to authoritatively answer "is this key currently authorized?", denying the answer "I don't know right now, but here's what I knew 3 minutes ago" is often worse than serving last-known-good keys until the upstream recovers.

stale_ttl lets operators opt into that trade: prefer availability for a bounded window when fresh data is unobtainable, knowing the trade-off (see security note below).

This is the same pattern as HTTP's stale-if-error (RFC 5861).

Proposed config

cache_ttl: 5m
stale_ttl: 1h
sources:
- url: https://github.com/dolph.keys

Default 0, which means disabled — current behavior is preserved on upgrade. Opting in is explicit.

stale_ttl is parsed the same way as cache_ttl (time.ParseDuration) and resolved through the same kind of fallback as Config.ResolveCacheTTL.

Semantics

Three windows for any cached entry, measured from setAt:

0 ─── cache_ttl ─── cache_ttl + stale_ttl ─── ∞
   FRESH         STALE-USABLE-ON-ERROR       UNUSABLE

Decision tree on each GetURL / GetGHE call:

  1. Cache hit and time.Since(setAt) < cache_ttlFRESH: return cached keys without touching the network. (Existing behavior.)
  2. Otherwise attempt the fetch.
    • Success → cache new bytes, return new keys. (Existing behavior.)
    • Failure (transport error, body read error, non-2xx):

A "Failure" here is any of the conditions PR #10 made non-fatal: transport error, body read error, non-2xx response.

Security trade-off — call this out in the README

Stale serving means a key revoked on the upstream can still authenticate during the stale window if the upstream happens to be unreachable. The window is bounded by stale_ttl, so the operator gets to decide how much availability they're willing to trade for instant revocation:

  • stale_ttl: 0 (default) — strict: a failed upstream means no keys.
  • stale_ttl: 5m — modest grace: tolerates very short blips.
  • stale_ttl: 24h — strong availability bias: tolerates a full day of upstream outage at the cost of revocations taking up to a day to propagate during sustained failures.

Worth a paragraph in the cache_ttl README section explaining the interaction.

Implementation sketch

Touches the same files as PR #8 / PR #10:

  • config.go: add StaleTTL string to Config, plus (c *Config) ResolveStaleTTL() time.Duration mirroring ResolveCacheTTL. Default 0.
  • httpclient.go: Client carries staleTTL alongside ttl. NewHTTPClient(ttl, staleTTL time.Duration). Restructure GetURL so the cache lookup is reusable across the success path and the on-error fallback. GetGHE mirrors the same shape.
  • cmd.go: pass c.ResolveStaleTTL() through to NewHTTPClient.
  • README.md: extend the cache_ttl section with the stale_ttl knob and the security trade-off.
  • httpclient_test.go: extend with cases that exercise the stale-fallback branch — pre-populate cache with an aged entry, point client at a server returning 500, expect the cached keys back.

The cache file format from PR #8 already records setAt, so no schema change.

Acceptance criteria

  • Config.StaleTTL parses via time.ParseDuration; default 0 when unset or unparseable.
  • On fetch failure, an entry within cache_ttl + stale_ttl of setAt is returned and logged distinctly from fresh hits (e.g. Cache STALE).
  • On fetch failure with no usable cache entry (no entry, or older than cache_ttl + stale_ttl), behavior matches current post-Stop killing the process on HTTP fetch errors (closes #2) #10 (logged failure, empty slice).
  • On fetch success, the just-fetched body is cached and returned (cache is always refreshed on success regardless of the previous entry's age).
  • Tests cover: success-path unchanged; stale-fallback hit; stale-fallback miss (entry too old); no-cache miss with failed fetch.
  • README has a paragraph on the availability ↔ revocation-latency trade-off.

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions