Skip to content

fix(filecache): clamp negative cache age instead of raising (#43) - #44

Merged
paultiq merged 5 commits into
paultiq:mainfrom
joseturegano:fix/filecache-clamp-negative-age-on-clock-skew
Aug 13, 2026
Merged

fix(filecache): clamp negative cache age instead of raising (#43)#44
paultiq merged 5 commits into
paultiq:mainfrom
joseturegano:fix/filecache-clamp-negative-age-on-clock-skew

Conversation

@joseturegano

Copy link
Copy Markdown
Contributor

Closes #43. You said you'd take this one, so treat this as a draft you're free to discard — I had the reproduction and the test harness already set up, so it seemed cheaper to hand you something reviewable than to leave you to rebuild it.

The fix

age: int = round(time.time() - float(fetched))
if age < 0:
    logger.warning(...)
    age = 0

fetched is the origin's Date header (it becomes _TeeCore.atime), not the local clock, so a machine running behind the origin reads a negative age and the guard raised uncaught, out through the transport and into the caller's request.

Correcting my own issue text

The issue said "treat it as not fresh, go revalidate" and then showed max(0, ...), which does the opposite — age 0 is the freshest possible reading, so the entry is served. I didn't notice the contradiction when I filed it. The snippet is the one that's right, and here's why I think so rather than just picking the convenient one:

A negative age means the entry was fetched at an origin time ahead of ours — i.e. it was just downloaded. It is genuinely fresh; the negative number is a clock artifact, not evidence of staleness. Treating it as stale would mean a machine with a persistently slow clock never gets a cache hit on any TTL rule, for as long as the clock is off. That trades a loud failure for a silent one, which seemed strictly worse for a caching library.

Happy to flip it to revalidate-instead if you read it differently — it's a one-line change and the second test below already pins the boundary either way.

Tests

Both in tests/test_revalidation.py, following the existing MockTransport + monkeypatch(time.time) pattern in that file. Deterministic — no reliance on real clock skew.

test_origin_clock_ahead_serves_cache_instead_of_raising — serves a Date header 120s ahead of a frozen local clock, then requests again inside the TTL, through the real request path (client.streamCachingTransportget_if_fresh). Revert the clamp and it fails with the original error:

ValueError: Age is less than 0, impossible age=-120, file path='/file.bin'

test_clamp_does_not_make_expired_entries_look_fresh — the negative that kills the tempting wrong fix. Hard-coding age = 0 unconditionally also stops the crash, while silently making every entry immortal. Honest note: I verified this mutant, and three of your existing revalidation tests already fail on it — so this test adds no detection power you don't have. Its only job is to state the invariant next to the clamp. Drop it if you consider that redundant; the PR stands without it.

Verification

tests/test_revalidation.py         5 passed
full suite                        46 passed, 2 skipped
  (uv sync --extra httpx2 --group edgartest, as .github/workflows/test.yml does)
ruff check                        clean on both changed files

The diff is additive on tests/test_revalidation.py (124 insertions, 0 deletions) — I deliberately kept my formatter off your existing code, so the only deletions anywhere are the two lines of the raise itself.

Two judgment calls, flagged rather than buried

  1. logger.warning, not debug. On a persistently skewed machine this fires per cache read. I went with warning because a clock that far off is a real environment problem worth surfacing, and the surrounding code already logs per call at info. One word to change if you'd rather it were quiet.
  2. # pragma: no cover removed, since the branch is now covered.

Why I care about this one

I hit it downstream in edgartools (dgunning/edgartools#989): a host-matching bug there meant its TTL rules never matched data.sec.gov, which made this branch accidentally unreachable end-to-end. Fixing that removes the shield, so that PR is parked as a draft until this ships. No pressure on timing — I mention it only so the ordering makes sense.

`get_if_fresh` compares an entry's `fetched` timestamp against the local
clock, but `fetched` is the ORIGIN's `Date` header (it becomes
`_TeeCore.atime`). On a machine whose clock runs behind the origin the
subtraction is negative and the guard raised `ValueError` uncaught,
propagating out of the transport and killing the caller's request.

Clamp to 0 rather than raise. A negative age means the entry was fetched
at an origin time ahead of ours — that is, it was just downloaded — so 0
is the truthful reading and the entry is served. Treating it as stale
would be the other plausible reading, but it would defeat the cache on
every single request for as long as the clock is off; that trades a loud
failure for a silent one.

`# pragma: no cover` is dropped: the branch is now covered.

Tests (tests/test_revalidation.py, deterministic, no real skew needed):

- `test_origin_clock_ahead_serves_cache_instead_of_raising` drives a
  120s-ahead `Date` header through the real request path. Reverting the
  clamp fails it with the original `ValueError: Age is less than 0,
  impossible age=-120`.
- `test_clamp_does_not_make_expired_entries_look_fresh` pins the
  invariant the clamp must not break: an unconditional `age = 0` also
  silences the crash while making every entry immortal.

Full suite: 46 passed, 2 skipped (`uv sync --extra httpx2 --group
edgartest`). ruff clean on both changed files.
New behavior is RFC 9111 compliant, so directly mention that and use a debug log message
Trailing whitespace
@paultiq

paultiq commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Thank you for the PR. I made a small edit to directly reference RFC 9111 since this change is directly consistent.

@paultiq
paultiq merged commit a24fff7 into paultiq:main Aug 13, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FileCache.get_if_fresh raises uncaught ValueError on client clock skew (age < 0) instead of degrading to a cache miss

2 participants