fix(filecache): clamp negative cache age instead of raising (#43) - #44
Merged
paultiq merged 5 commits intoAug 13, 2026
Merged
Conversation
`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
Owner
|
Thank you for the PR. I made a small edit to directly reference RFC 9111 since this change is directly consistent. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
fetchedis the origin'sDateheader (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 existingMockTransport+monkeypatch(time.time)pattern in that file. Deterministic — no reliance on real clock skew.test_origin_clock_ahead_serves_cache_instead_of_raising— serves aDateheader 120s ahead of a frozen local clock, then requests again inside the TTL, through the real request path (client.stream→CachingTransport→get_if_fresh). Revert the clamp and it fails with the original error:test_clamp_does_not_make_expired_entries_look_fresh— the negative that kills the tempting wrong fix. Hard-codingage = 0unconditionally 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
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 theraiseitself.Two judgment calls, flagged rather than buried
logger.warning, notdebug. On a persistently skewed machine this fires per cache read. I went withwarningbecause a clock that far off is a real environment problem worth surfacing, and the surrounding code already logs per call atinfo. One word to change if you'd rather it were quiet.# pragma: no coverremoved, 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.