Skip to content

perf: cut pricing refresh traffic with gzip and ETag revalidation - #1565

Closed
wishworldbetter wants to merge 7 commits into
ccusage:mainfrom
wishworldbetter:pricing-http-cache
Closed

wishworldbetter wants to merge 7 commits into
ccusage:mainfrom
wishworldbetter:pricing-http-cache

Conversation

@wishworldbetter

@wishworldbetter wishworldbetter commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1564

What

The LiteLLM pricing fetch downloaded the full table uncompressed on every non-offline run: ureq was built without its gzip feature, so no Accept-Encoding was sent (1.7 MB on the wire), and nothing revalidated, so polling callers (statuslines, usage collectors) paid that full price every run.

  • Enable ureq's gzip feature — the same fetch now transfers ~82 KB.
  • Keep each response body on disk next to its ETag (best-effort, same spirit as the statusline cache) and send If-None-Match on later runs: an unchanged document is a bodyless 304, ~zero transfer. GitHub serves the pricing table with a strong ETag, so this is the common case.
  • Serve the cached body when revalidation fails, so a network hiccup degrades to the last validated table instead of embedded-only data.

The models.dev fetch goes through the same fetcher and gets the same treatment for free.

Measured

Real setup polling once a minute (curl cross-check: identity 1.7 MB / gzip 82 KB / If-None-Match match → HTTP 304, empty body):

per run
before 1764 KB
after, pricing changed (cold) 162 KB
after, pricing unchanged (common case) ~8 KB — TLS handshake + 304 headers

Tested

  • 3 new unit tests for the cache (round-trip, torn/unusable ETag rejection, filesystem-safe stems); cargo test -p ccusage 53/53, fmt and clippy clean.
  • Manual: cold run writes $TMPDIR/ccusage-http-cache/*.body/.etag; warm run leaves the pair untouched (304 path) and produces identical report output.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Cuts pricing refresh bandwidth with gzip and ETag revalidation and scopes the HTTP cache to 304-only use. Previously we downloaded ~1.7MB uncompressed every run and could serve stale cache on errors; now gzip + If-None-Match cut transfer, and any fetch failure surfaces so the embedded snapshot takes over.

  • Enables ureq gzip and bounds decompressed reads; sends If-None-Match and serves the cached body only on HTTP 304; any non-200/304 status or read error returns an error (no stale-on-error fallback).
  • Adds a per-user cache at XDG_CACHE_HOME or ~/.cache/ccusage/http-cache; stores ETag+body in one file and replaces via atomic rename; rejects invalid ETags and bounds cache-file reads; no world-shared temp fallback.
  • Defers cache writes until a schema parse succeeds via FetchedJson::commit; both LiteLLM and models.dev loaders call commit only after successful parses; models.dev now requires at least one loaded entry before retention/commit.
  • Applies to LiteLLM pricing and the models.dev fetcher; tests cover 200→commit→304, failure with a cached copy present, cache-less mode, filename safety, and the models.dev zero-entries case.

Written for commit 3031019. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Performance

    • Added disk-backed caching to reduce repeated downloads.
    • Enabled compressed transfers for more efficient data retrieval.
    • Cached responses are revalidated with ETags when available.
  • Reliability

    • Added safeguards against invalid or oversized responses.
    • Cache entries are saved only after successful data validation.
    • Improved handling of retries and transient service responses.
    • Invalid responses are rejected without replacing valid cached data.

The pricing fetch downloaded the full LiteLLM table uncompressed on every
run: ureq was built without its gzip feature, so no Accept-Encoding was
sent (1.67MB on the wire), and nothing revalidated, so polling callers
(statuslines, collectors) paid that full price every time.

- Enable ureq's gzip feature: the same fetch now transfers ~82KB.
- Keep each response body on disk next to its ETag (best-effort, in the
  spirit of the statusline cache) and send If-None-Match on later runs:
  an unchanged document is a bodyless 304, ~zero transfer. GitHub serves
  the pricing table with a strong ETag, so this is the common case.
- Serve the cached body when revalidation fails, so a network hiccup
  degrades to yesterday's validated table instead of embedded-only data.

Measured on a live setup polling once a minute: 1764KB per run before,
162KB cold / ~1KB warm (304) after.

Co-Authored-By: Claude Fable 5 <[email protected]>
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

This PR was auto-closed. Only contributors approved with lgtm can open PRs. Open an issue first.

Maintainers review auto-closed issues and reopen worthwhile ones. Issues that do not meet the quality bar in CONTRIBUTING.md may not be reopened or receive a reply.

If a maintainer replies lgtmi, your future issues will stay open. If a maintainer replies lgtm, your future issues and PRs will stay open.

See CONTRIBUTING.md.

@github-actions github-actions Bot closed this Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c21da55-8e5b-4ff6-a4e0-03de1b2b8377

📥 Commits

Reviewing files that changed from the base of the PR and between 4351b86 and 24d79fb.

📒 Files selected for processing (2)
  • rust/crates/ccusage-core/src/pricing.rs
  • rust/crates/ccusage/src/http.rs

📝 Walkthrough

Walkthrough

The pricing fetch enables gzip and uses disk-backed ETag caching. It bounds decompressed and cached reads, writes cache entries atomically, and commits cache writes only after valid pricing data loads.

Changes

Pricing fetch cache

Layer / File(s) Summary
HTTP fetch and revalidation
rust/Cargo.toml, rust/crates/ccusage/src/http.rs
ureq enables gzip. fetch_json selects a safe cache directory, sends If-None-Match, serves cached bodies only for 304 responses, bounds decompressed reads, and returns fetch failures as errors.
Cache storage and validation
rust/crates/ccusage/src/http.rs
Cache entries validate ETags, use bounded reads and atomic replacement, and use hashed, filesystem-safe URL names. Tests cover round trips, invalid entries, rewrites, temporary-file cleanup, filename safety, and cache-directory selection.
Deferred validation and pricing loaders
rust/crates/ccusage-core/src/pricing.rs
FetchedJson defers cache commits. LiteLLM and models.dev loaders parse responses first and commit only after usable pricing data is accepted. Tests cover invalid responses, successful commits, retries, and backoff.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🔵 Low · up to 24d79

The PR reduces pricing refresh traffic and improves offline resilience, but a corrupted cached response can persist across revalidations and repeatedly cause fallback behavior. The change is mergeable with explicit owner awareness and follow-up to validate or evict unusable cached bodies.

Sequence Diagram(s)

sequenceDiagram
  participant PricingLoader
  participant fetch_json
  participant PricingEndpoint
  participant CacheEntry
  PricingLoader->>fetch_json: Request pricing JSON
  fetch_json->>CacheEntry: Read cached body and ETag
  fetch_json->>PricingEndpoint: Send conditional request
  PricingEndpoint-->>fetch_json: Return response body or 304
  fetch_json-->>PricingLoader: Return FetchedJson
  PricingLoader->>PricingLoader: Parse and validate pricing data
  PricingLoader->>CacheEntry: Commit validated body and ETag
Loading

Possibly related PRs

Suggested reviewers: ryoppippi, pullfrog

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #1564 by enabling gzip, persisting validated body and ETag data, sending If-None-Match, and using cached bodies only after 304 responses.
Out of Scope Changes check ✅ Passed The additional cache safety, validation, bounded reads, portability, and test changes directly support the pricing fetch optimization objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main changes: enabling gzip and reducing pricing refresh traffic through ETag revalidation.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch pricing-http-cache
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • rust/Cargo.toml — enables ureq's gzip feature so Accept-Encoding: gzip is sent and compressed responses are transparently decoded
  • rust/crates/ccusage/src/http.rs — adds an on-disk ETag cache under $TMPDIR/ccusage-http-cache/ with If-None-Match revalidation, 304 handling, and network-failure fallback to the last validated body; includes FNV-1a hashing for collision-resistant filesystem-safe stems and 3 unit tests

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

The body and ETag lived in two files written back-to-back, so two
concurrent runs (a statusline plus a polling collector) could interleave
and pair one run's body with the other's ETag — revalidation would then
vouch for a body that does not match the validator until the next real
upstream change.

Store the pair in a single file (first line ETag, rest body), written to
a per-process temp file and renamed into place: one atomic rename swaps
both together, so readers see the old pair or the new pair, never a mix.

Co-Authored-By: Claude Fable 5 <[email protected]>
@ryoppippi ryoppippi reopened this Aug 2, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

> ✅ No new issues found.

Reviewed changes

  • Single-file cache with atomic rename — replaced the separate .body/.etag files with one .cache file (ETag as first line, body follows). Writes go through a per-process temp file (stem.tmp<PID>) and rename into place, so concurrent readers observe either the old or the new pair atomically, never a body paired with another run's ETag.
  • Truncated-entry guard — added a test case for missing newline: a cache file with no \n returns None instead of being used for revalidation.
  • Expanded atomic test — cache_write_replaces_pair_atomically_and_cleans_up verifies rewrite correctness, multi-line body round-trip, and that no temp files linger after a successful write.

<!--
Pullfrog review metadata. These findings were written against 2c80ac2;
if commits have landed on pricing-http-cache since, treat every specific bug, file, or
line callout as POTENTIALLY STALE and re-diff before acting on it.

  • Mode: IncrementalReview (delta against prior pullfrog review)
  • Files reviewed: 3
  • Commits reviewed: 2
  • Base: main (eda5e50)
  • Head: pricing-http-cache (2c80ac2)
  • Reviewed commits:
    • 80e189e — perf: cut pricing refresh traffic with gzip and ETag revalidation
    • 2c80ac2 — fix: replace the cache pair atomically for concurrent runs
  • Prior pullfrog review: 80e189e (#1565 (review))
    -->

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread rust/crates/ccusage/src/http.rs Outdated
Comment thread rust/crates/ccusage/src/http.rs
Comment thread rust/crates/ccusage/src/http.rs
Findings from an adversarial review of the previous two commits:

- ureq's `.limit()` sits below the gzip decoder, so with gzip enabled it
  only capped the compressed stream — a hostile origin could balloon
  memory ~1000x through it. Read via `.reader()` with `take()` so the
  bound applies to what actually lands in memory.
- A 4xx other than 429 now surfaces as an error instead of silently
  serving the stale cache forever: if the URL moves or dies, the
  embedded snapshot (refreshed with every release) wins over a cache
  frozen at the last successful fetch. 5xx/429/transport errors still
  serve the last validated body, now with a WARN at log level 4+.
- A body that dies mid-read (timeout, truncated stream, oversize) gets
  the same stale fallback as a failed request, as the comment promised.
- Temp file names include a per-call counter besides the pid, and a
  failed write cleans its half-written temp file up.
- The cache moved from the temp dir to XDG_CACHE_HOME/~/.cache: /tmp is
  world-shared on Linux, so another local user could plant entries or
  symlink the predictable temp path.
- Docs: the gzip variant of the pricing table carries a weak ETag, not
  the strong one served for identity.

Co-Authored-By: Claude Fable 5 <[email protected]>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Cache directory moved to XDG_CACHE_HOME — switched from /tmp to ~/.cache/ccusage/http-cache (via XDG_CACHE_HOME or HOME), with temp dir as fallback; cache_dir_under() rejects relative paths per the basedir spec
  • 4xx status-code differentiation — non-429 4xx codes are now hard errors surfaced to the embedded-snapshot fallback; 5xx/429/transport errors still fall back to the last validated cached body
  • Stale-cache logging — log_stale_cache_use() mirrors the pricing refresh WARN pattern, gated on ccusage_core::log_level() >= 4
  • Decompressed body-size bounding — replaced ureq's .limit() (compressed-stream cap only) with reader().take(PRICING_FETCH_MAX_BYTES + 1) to guard against gzip decompression bombs
  • Body-read failure fallback — mid-read errors (timeout, truncation, oversize) now fall back to the cached response instead of failing
  • Per-call temp file naming — added AtomicU64 sequence counter so two threads in the same process racing on the same URL use distinct temp files
  • Write-failure cleanup — half-written temp files (e.g. disk full) are now removed
  • New test — cache_dir_prefers_xdg_then_home_then_temp covers relative-path rejection and fallback order

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread rust/crates/ccusage/src/http.rs Outdated
…ore caching

Addresses both findings from the cubic review:

- The permanent-failure branch is now an allowlist (404, 410) instead of
  "every 4xx but 429": 408, 421, 425 and friends can be passing
  conditions, so they serve the last validated cached copy like 5xx and
  transport errors do, instead of dropping to the embedded snapshot.
- A 200 body must parse as JSON before it may replace the cached copy.
  The stale fallback is only sound while the copy on disk is known-good;
  a hijacked or errored 200 (captive portal, CDN error page) returning
  non-JSON with an ETag would otherwise evict the last good table. The
  invalid body is still handed to the caller unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
rust/crates/ccusage/src/http.rs (1)

65-95: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Add fixture-backed tests for revalidation behavior.

The current tests do not call fetch_json_with_cache_dir. Add loader tests that verify If-None-Match is sent, HTTP 304 returns the cached body, and transient request or read failures preserve the cached body. Run the applicable just test recipe after adding them.

As per coding guidelines, “Prefer fixture-backed parser and loader tests for Rust code,” and “Use just as the single entry point for repository tasks and consult just --list for available recipes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/crates/ccusage/src/http.rs` around lines 65 - 95, Add fixture-backed
loader tests that exercise fetch_json_with_cache_dir, covering If-None-Match
request headers, HTTP 304 returning the cached body, and transient request or
response-read failures falling back to the cached body. Reuse the existing HTTP
fixture/test infrastructure and add the tests near the current loader tests,
then run the applicable recipe discovered via just --list.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/crates/ccusage/src/http.rs`:
- Around line 137-145: Update CacheEntry::read to validate the cached body with
looks_like_json before accepting an entry, including the valid-ETag path. Reject
and bypass malformed or truncated cache contents so 304 and fetch-failure
handling can use the next fallback source, while preserving valid cached
responses.

---

Nitpick comments:
In `@rust/crates/ccusage/src/http.rs`:
- Around line 65-95: Add fixture-backed loader tests that exercise
fetch_json_with_cache_dir, covering If-None-Match request headers, HTTP 304
returning the cached body, and transient request or response-read failures
falling back to the cached body. Reuse the existing HTTP fixture/test
infrastructure and add the tests near the current loader tests, then run the
applicable recipe discovered via just --list.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fbc4c770-93c6-49c3-b8e8-553761dc3ed8

📥 Commits

Reviewing files that changed from the base of the PR and between 2157d3e and f2a3cee.

📒 Files selected for processing (1)
  • rust/crates/ccusage/src/http.rs

Comment thread rust/crates/ccusage/src/http.rs Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Narrowed permanent HTTP failures to 404/410 only — replaced the catch-all non-429 4xx error branch with is_permanent_http_failure(), so 400, 401, 403, 408, 421, and 425 now fall back to the cached body instead of surfacing hard errors. A 404 or 410 still signals a dead URL and escalates to the embedded snapshot.
  • JSON-gated cache writes — added looks_like_json() guard so only responses that parse as valid JSON can evict the on-disk cache; a hijacked 200 (captive portal, CDN error page) still reaches the caller but can't corrupt the cache.
  • Two new tests — permanent_failure_is_a_short_allowlist exhaustively checks the classification table across 12 status codes, and only_valid_json_may_replace_the_cached_copy covers valid objects/arrays alongside HTML, truncated JSON, and empty strings.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

ccusage

npx https://pkg.pr.new/ccusage@1565

@ccusage/ccusage-darwin-arm64

npx https://pkg.pr.new/@ccusage/ccusage-darwin-arm64@1565

@ccusage/ccusage-darwin-x64

npx https://pkg.pr.new/@ccusage/ccusage-darwin-x64@1565

@ccusage/ccusage-linux-arm64

npx https://pkg.pr.new/@ccusage/ccusage-linux-arm64@1565

@ccusage/ccusage-linux-x64

npx https://pkg.pr.new/@ccusage/ccusage-linux-x64@1565

@ccusage/ccusage-win32-x64

npx https://pkg.pr.new/@ccusage/ccusage-win32-x64@1565

commit: f2a3cee

@ryoppippi

Copy link
Copy Markdown
Member

@wishworldbetter Thanks for the PR! I ran two independent review passes over this branch and want to share both the findings and a direction decision before you invest more time in fixes.

Overall judgment

The core of this PR is worth merging: today every non-offline run re-downloads the full ~1.7MB LiteLLM JSON, so gzip plus an ETag/304 path is a real win. However, most of the review findings below stem from one added feature: serving the stale cached body as a fallback when the network fails. That fallback competes with the embedded pricing snapshot we already ship (which is the intended failure/offline fallback) and drags in a lot of subtle cache-semantics problems — for example:

  • No age bound, so an arbitrarily old cached body silently overrides the fresher embedded pricing shipped with every release whenever a proxy persistently returns a "transient" error.
  • A 404/410 correctly returns an error, but a later DNS failure or timeout resurrects the cache entry the permanent-failure branch was supposed to retire.
  • A 200 without an ETag neither refreshes nor evicts the stored pair, so the fallback can serve a body older than the last successful fetch.
  • Unexpected 2xx statuses (203 from a rewriting proxy) hard-fail without the fallback that every 4xx/5xx gets.

Rather than patching each of these, I'd like to reduce the scope:

  • Keep: gzip, ETag storage, and 304 → return the cached body.
  • Drop: serving the cached body on network/HTTP failure. On any failure, return Err as main does today and let the embedded pricing take over.

That removes the findings above by design, keeps the failure behavior identical to what users have now, and shrinks the diff to a reviewable size.

Findings that still apply after the scope cut

  1. Shared /tmp fallback (rust/crates/ccusage/src/http.rs:52): when HOME and XDG_CACHE_HOME are both unset, the cache lands in the predictable, world-shared /tmp/ccusage/http-cache. Another local user can plant an ETag+body pair that revalidates via 304 into trusted pricing data, and the temp-file writes can be redirected via symlinks. Please disable caching entirely when no per-user directory can be resolved.
  2. Drive the cache write from the caller's successful schema parse, not looks_like_json (rust/crates/ccusage/src/http.rs:142-158): a 200 serving a JSON-shaped error envelope currently evicts the known-good pair, and looks_like_json fully parses the ~1.7MB body only to discard the result, duplicating the parse the caller performs immediately afterwards.
  3. default_cache_dir reads only HOME (rust/crates/ccusage/src/http.rs:36): please reuse the existing ccusage_core::home::home_dir() (it handles USERPROFILE / HOMEDRIVE+HOMEPATH), otherwise the cache silently lands in %TEMP% on Windows, which Storage Sense purges.
  4. Unbounded cache-file read (rust/crates/ccusage/src/http.rs:192): the network body is capped at 64MB but fs::read_to_string on the cache file has no bound; please apply the same limit.
  5. Windows test failure (rust/crates/ccusage/src/http.rs:380): paths like /xdg and /home/u are not absolute on Windows (Path::is_absolute() requires a drive or UNC prefix), so the first assertion falls through and the test fails on the supported Windows target. Use target-native fixture paths or gate by platform.
  6. Test conventions (rust/crates/ccusage/src/http.rs:267): tests should use the ccusage-test-support fixture crate instead of hand-rolled env::temp_dir() paths — the current cleanup also leaks directories when an assertion panics and makes reruns order-dependent.

If you'd rather I take over the scope reduction, happy to do it — otherwise, looking forward to the update. Thanks again!

@ryoppippi

Copy link
Copy Markdown
Member

@pullfrog do you want to work on it?

@pullfrog

pullfrog Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Implemented and committed the requested PR #1565 review direction as f65907d (fix(pricing): scope HTTP cache to validated responses). The change removes stale-on-error fallback, disables caching without an absolute per-user directory, uses ccusage_core::home::home_dir(), bounds cache reads, commits cache entries only after successful pricing-schema parsing, and adds platform-safe fixture plus mock HTTP coverage. Final verification passed: 58 ccusage tests, 114 core tests, clippy with -D warnings, and Rust format checks.

The working tree is clean, but publishing is blocked by repository permissions. Both push attempts failed with: remote: Permission to wishworldbetter/ccusage.git denied to github-actions[bot]. fatal: unable to access 'https://github.com/wishworldbetter/ccusage.git/': The requested URL returned error: 403.

Task list (5/5 completed)

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | via Pullfrog | Using GPT Luna (free via Pullfrog for OSS) | 𝕏

Implements the maintainer review direction on ccusage#1565: the cache no
longer stands in for the network, it only answers If-None-Match
revalidation.

- Any request failure, non-200/304 status, or body-read failure now
  returns an error so the embedded pricing snapshot takes over, exactly
  as main behaves without a cache. The stale-on-error fallback and its
  permanent/transient status split are gone.
- The cache write is deferred to the pricing loaders: fetch_json
  returns a FetchedJson whose commit() runs only after LiteLLM or
  models.dev schema parsing accepts the body, so a JSON-shaped error
  envelope can no longer evict the last validated pair, and the
  duplicate looks_like_json parse is gone.
- Caching is disabled entirely when no absolute per-user cache
  directory can be resolved instead of falling back to a world-shared
  temp dir, and the home lookup goes through
  ccusage_core::home::home_dir so Windows profiles resolve.
- Cache-file reads are bounded like the network read.
- Tests use ccusage-test-support fixtures, platform-safe absolute
  paths, and a scripted local HTTP server covering the
  200-commit-304 cycle, failure with a cached copy present, and the
  cache-less fetch.

Co-Authored-By: Claude Fable 5 <[email protected]>
@wishworldbetter

Copy link
Copy Markdown
Contributor Author

@ryoppippi The scope reduction is done in 24d79fb: the cache now only answers If-None-Match revalidation, and every fetch failure surfaces as an error so the embedded snapshot takes over exactly as on main. The stale-on-error fallback and its permanent/transient status split are gone.

The six remaining findings are addressed as well:

  1. Caching is disabled entirely when no absolute per-user cache directory can be resolved — no world-shared temp fallback.
  2. Cache writes are driven by the loaders' successful schema parse: fetch_json returns a FetchedJson whose commit() the LiteLLM/models.dev loaders call only after the body produced usable pricing, so a JSON-shaped error envelope can no longer evict the last validated pair, and the duplicate looks_like_json parse is gone.
  3. The home lookup goes through ccusage_core::home::home_dir(), so Windows profiles resolve instead of landing in %TEMP%.
  4. Cache-file reads are bounded by the same 64MB limit as the network read.
    5./6. The tests now use ccusage-test-support fixtures with platform-safe absolute paths, and a scripted local HTTP server covers the 200 → commit → If-None-Match → 304 cycle, a failure with a cached copy present (must error, not serve stale), and the cache-less fetch.

Verified locally against the pinned toolchain: all 587 workspace tests pass, clippy -D warnings is clean, cargo fmt is clean, and cargo hawk check reports 0 findings.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new deferred validation can persist a syntactically valid but unusable models.dev response as a successful cache entry.

Reviewed changes

The incremental delta was reviewed against the prior Pullfrog review, including the cache's 304-only behavior and the deferred commit plumbing shared by LiteLLM and models.dev.

  • Scoped cache reuse — changed fetch failures to surface to embedded pricing while retaining the disk body only for successful 304 revalidation.
  • Deferred cache writes — returned FetchedJson values from the pricing fetcher and committed new bodies only after the pricing loaders accepted them.
  • Expanded loader coverage — added tests for commit verdicts, cache-less operation, revalidation headers, and failed refreshes.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Luna (free via Pullfrog for OSS) | 𝕏

Comment thread rust/crates/ccusage-core/src/pricing.rs

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread rust/crates/ccusage-core/src/pricing.rs Outdated
A models.dev body that parses but prices nothing — `{}`, or providers
whose models all lack usable costs — was retained as the process-wide
map and committed to the HTTP cache, so a later 304 kept serving the
empty document until upstream changed the ETag. Require at least one
loaded entry before retaining the map or running the commit hook,
matching how the LiteLLM loader already gates its commit on
loaded_count. Raised by pullfrog and cubic on ccusage#1565.

Co-Authored-By: Claude Fable 5 <[email protected]>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="rust/crates/ccusage-core/src/pricing.rs">

<violation number="1" location="rust/crates/ccusage-core/src/pricing.rs:1472">
P2: When an existing cache contains `{}`, a 304 repeatedly feeds that body into this rejection and returns `None` without invalidating its ETag. Invalidate the unusable cache entry or force an unconditional retry after rejecting a 304 body.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// the process and committing it would keep a 304 serving it across runs.
if map
.load_models_dev_json_missing(fetched.body())
.unwrap_or(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an existing cache contains {}, a 304 repeatedly feeds that body into this rejection and returns None without invalidating its ETag. Invalidate the unusable cache entry or force an unconditional retry after rejecting a 304 body.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At rust/crates/ccusage-core/src/pricing.rs, line 1472:

<comment>When an existing cache contains `{}`, a 304 repeatedly feeds that body into this rejection and returns `None` without invalidating its ETag. Invalidate the unusable cache entry or force an unconditional retry after rejecting a 304 body.</comment>

<file context>
@@ -1464,7 +1464,14 @@ where
+    // the process and committing it would keep a 304 serving it across runs.
+    if map
+        .load_models_dev_json_missing(fetched.body())
+        .unwrap_or(0)
+        == 0
+    {
</file context>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new zero-entry guard can permanently poison an existing models.dev cache entry. Please invalidate or bypass unusable 304 bodies before merging.

Reviewed changes

The incremental review covers the new models.dev zero-entry validation and its regression test added after the prior Pullfrog review.

  • Rejected empty models.dev refreshes — treated documents that load zero usable pricing entries as failed refreshes, preventing them from being retained in process or committed to disk.
  • Added zero-entry regression coverage — verified that {} is rejected and does not invoke the deferred cache commit hook.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Luna (free via Pullfrog for OSS) | 𝕏

// empty object parses, but retaining it would pin an unpriceable map for
// the process and committing it would keep a 304 serving it across runs.
if map
.load_models_dev_json_missing(fetched.body())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When {} was already written by an older loader, this new zero-entry check rejects it, but the 304 path in http.rs keeps returning the cached body and ETag. Every refresh therefore repeats the 304 and returns to the embedded fallback without ever issuing an unconditional request, so the cache remains poisoned across runs. Invalidate the unusable entry or retry once without If-None-Match after rejecting the 304 body.

Technical details
# Recover from unusable 304 cache bodies

## Affected sites
- `rust/crates/ccusage-core/src/pricing.rs:1471` — the new zero-entry gate rejects a cached `{}` document but does not communicate that the HTTP cache entry must be discarded.
- `rust/crates/ccusage/src/http.rs:76-80` — a `304` returns the cached body without changing or invalidating its ETag.

## Required outcome
- When a `304` body fails the models.dev usable-entry validation, the next attempt must be able to fetch a fresh `200` response instead of sending the same validator forever.

## Suggested approach
- Invalidate the unusable cache pair or retry the request once without `If-None-Match` when the cached body is rejected.

@ryoppippi ryoppippi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for incorporating the scope reduction from the earlier discussion. I found one remaining correctness issue before this can merge.

[P2] Invalidate or bypass a cached body that fails validation after a 304

fetch_json_with_cache_dir returns FetchedJson::new(cached.body) when the server responds with 304. The pricing loaders now correctly reject zero usable entries, including {}, and do not call commit, but there is no way to invalidate the existing cache or ETag after that rejection. As a result, a cache written by an earlier revision—or a malformed/truncated cache that passes the cache-file framing checks—can loop forever:

If-None-Match -> 304 -> same invalid body -> reject

The next process repeats the same sequence, so models.dev pricing never recovers until the upstream ETag changes.

Please either add a rejection/invalidation hook so the loader can remove the cache after rejecting a 304 body, or retry once without If-None-Match when the cached 304 body fails validation. Please also add a regression test that seeds an existing {} cache, returns 304, and verifies that the next request bypasses the ETag and can refresh with a 200 body.

The gzip/ETag/304 direction and the removal of the stale-on-error fallback look good; this is the remaining blocker from my side.

@ryoppippi

Copy link
Copy Markdown
Member

Superseded by #1672, which implements the safer ETag refresh and cache validation path. Closing this stale alternative.

@ryoppippi ryoppippi closed this Aug 31, 2026
@ryoppippi ryoppippi added the triage:resolved Resolved by a later change or current implementation. label Aug 31, 2026
@ryoppippi

Copy link
Copy Markdown
Member

Historical audit: this pull request was auto-closed by the legacy contributor gate. That closure did not assess technical importance.

Audit result: resolved. A later merged change or the current main implementation covers this request. This PR is kept for history and does not need to be revived.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage:resolved Resolved by a later change or current implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pricing refresh: send Accept-Encoding and revalidate with ETag (1.7MB -> ~8KB per run)

2 participants