feat(CLI): add --cache flag to phrase pull for conditional requests#1066
feat(CLI): add --cache flag to phrase pull for conditional requests#1066Thibaut Etienne (tetienne) wants to merge 1 commit intophrase:mainfrom
Conversation
edb8391 to
5788c0a
Compare
Store ETag and Last-Modified response headers locally and send them as If-None-Match / If-Modified-Since on subsequent pulls. When the server returns 304 Not Modified, the locale file is skipped. Cache is stored at os.UserCacheDir()/phrase/download_cache.json. Only supported in sync mode; --cache with --async prints a warning and falls back to normal behavior. Fixes phrase/phrase-cli#164
5788c0a to
559bf93
Compare
|
Thank you Thibaut Etienne (@tetienne) for the suggested improvement. The team will review this PR within the next business days. |
| // serializeOpts extracts set values from optional fields into a deterministic map. | ||
| // It assumes all fields in LocaleDownloadOpts are either slices or antihax/optional | ||
| // types with IsSet()/Value() methods. Fields with other types are silently excluded. | ||
| func serializeOpts(opts phrase.LocaleDownloadOpts) string { |
There was a problem hiding this comment.
we could probably avoid having this function by reusing the logic from https://github.com/phrase/phrase-go/blob/master/api_locales.go#L407 which is essentially also generating a string from the download options.
right now that logic is embedded into the request function itself, but should be possible to extract it into a separate function and use that here as well (unfortunately, it would need to be done in the template file here https://github.com/phrase/strings-openapi/blob/main/openapi-generator/templates/go/api.mustache
There was a problem hiding this comment.
Ah yes good catch. It can be done into a follow-up PR indeed. Doing it here would increase the radius.
jablan
left a comment
There was a problem hiding this comment.
overall solid, a question for reusing existing code for params fingerprinting could be addressed later (at the price of invalidating existing caches due to different serialization).
We rely heavily on
phrase pullin our CI pipelines and have been frustrated by the slowness of repeated pulls. Every invocation downloads all locale files regardless of whether translations have changed, which is wasteful and frequently pushes us into rate limiting. This PR adds HTTP conditional request support to avoid re-downloading unchanged locales.Summary
--cacheflag tophrase pullthat stores ETag/Last-Modified response headers locally and sends them asIf-None-Match/If-Modified-Sinceon subsequent pullsos.UserCacheDir()/phrase/download_cache.json(XDG-compliant)--cachewith--asyncprints a warning and ignores cachingFixes phrase/phrase-cli#164
Usage
Real-world results
Tested against a production project with 379 locale files across 3 Phrase projects:
First run (populates cache, downloads everything):
Second run (sends ETags, all 304 Not Modified):
Cache file (
~/Library/Caches/phrase/download_cache.json):{ "version": 1, "entries": { "a1b2c3d4e5f6...": { "etag": "W/\"070896511a30e4295c7cd1825d0f49ee\"", "last_modified": "Wed, 18 Mar 2026 15:04:42 GMT" } } }phrase pullphrase pull --cacheWall-clock improvement is ~15%, but the main benefits are zero bandwidth on cache hit, reduced rate limit pressure, and lower server load. The bottleneck is 379 sequential HTTP round-trips; parallelizing downloads (out of scope) would amplify the time savings further.
GitHub Actions example
The
--cacheflag pairs well withactions/cacheto persist ETags between CI runs, significantly reducing both download time and API rate limit consumption:Design decisions
updated_since: The SDK'sUpdatedSinceparameter produces partial files (only keys updated after that date), not "skip if unchanged" semantics. ETags are the correct HTTP mechanism for conditional full-file downloads.LocaleDownloadOptsstruct via reflection to extract setoptional.*values. This ensures new SDK fields automatically affect the key without code changes.Save()short-circuits when no entries were modified, avoiding unnecessary writes on all-304 runs.>= 300as an error. We intercept 304 before checkingerrand returnerrNotModifiedso the caller can print "Not modified" instead of "Downloaded".If-None-Match/If-Modified-Sinceto ensure a full response.Test plan
go build ./...compiles (pre-existingcmd/error unrelated)go test ./cmd/internal/...passes (12 tests)go vet ./cmd/internal/cleanphrase pullwithout--cachebehaves identically to beforephrase pull --cache --asyncwarns and ignores cache