Skip to content

[workers-shared] Retry null KV reads when serving static assets#14649

Open
MattieTK wants to merge 1 commit into
mainfrom
fix/asset-worker-kv-null-retries
Open

[workers-shared] Retry null KV reads when serving static assets#14649
MattieTK wants to merge 1 commit into
mainfrom
fix/asset-worker-kv-null-retries

Conversation

@MattieTK

@MattieTK MattieTK commented Jul 10, 2026

Copy link
Copy Markdown
Member

Retry KV reads that return null when serving static assets, not just reads that error.

Immediately after a deploy there is a brief window where an asset referenced by the manifest may not yet have propagated to every KV storage provider, so a read can return null even though the asset exists. Previously this surfaced as an intermittent 500.

There are two failure routes in getAssetWithMetadataFromKV:

  • Route A – the KV read throws (a storage-layer error). Already retried with backoff.
  • Route B – the KV read returns null (asset not yet propagated). Previously did a single immediate re-read and then gave up. Now retried with the same jittered exponential backoff before returning.

The backoff is deliberately small with full jitter (roughly 0–50ms, 0–100ms, 0–200ms) because a single page load can fan out into hundreds of asset requests: long per-request stalls would hurt page load, and jitter avoids synchronised retries stampeding a provider that is still catching up. Retried reads continue to use the short cache TTL so a transient miss is never cached long-term.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: internal change to the Asset Worker's KV retry behaviour; no user-facing API or configuration surface.

A picture of a cute animal (not mandatory, but encouraged)


Open in Devin Review

A KV read for a static asset can fail two ways: the read throws (a
storage-layer error), or it returns null. A null can occur in the brief
window after a deploy where an asset referenced by the manifest has not
yet propagated to the KV storage provider being read from.

Previously only the throw path was retried with backoff; a null did a
single immediate re-read and then gave up, surfacing an intermittent 500.
Apply the same jittered exponential backoff retry loop to the null path so
propagation has a chance to catch up before we return.

The backoff is kept small with full jitter because a single page load can
fan out into hundreds of asset requests, so long per-request stalls would
hurt page load and synchronised retries could stampede a provider that is
still catching up. Retried reads keep the short cache TTL so a transient
miss is not cached long-term.
@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 13af4c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/workers-shared Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Jul 10, 2026
@workers-devprod workers-devprod requested review from a team and penalosa and removed request for a team July 10, 2026 13:15
@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/deploy-config
  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/asset-worker-kv-null-retries.md: [@cloudflare/wrangler]
  • packages/workers-shared/asset-worker/src/utils/kv.ts: [@cloudflare/deploy-config @cloudflare/wrangler]
  • packages/workers-shared/asset-worker/tests/kv.test.ts: [@cloudflare/deploy-config @cloudflare/wrangler]

@github-actions

Copy link
Copy Markdown
Contributor

✅ All changesets look good

@ask-bonk ask-bonk 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.

I'm Bonk, and I've done a quick review of your PR.

Summary: reworks getAssetWithMetadataFromKV to retry null KV reads (not just errored reads) with a small jittered exponential backoff, defaulting to 3 retries.

One real (latent) issue found around the cache-TTL guarantee when retries is 0; everything else looks good.

Issues

  1. (medium) retries = 0 regresses the "never cache a null for a year" guarantee. In the previous implementation a null value always triggered a re-read with the short (60s) TTL, regardless of the retry count, to avoid caching a transient 404 for a year. In the new loop, when retries === 0, attempt 0 is not a retry (so the read uses LONG_CACHE_TTL) and attempt < retries (0 < 0) is false, so the null result read with the 1-year TTL is returned and cached. The comment on lines 63-66 acknowledges this only holds "with the default retries (>= 1)", leaving a footgun for any caller that passes 0. Since the exported signature allows retries = 0, worth guarding against caching a long-TTL null. See suggestion below.

// always means the value has not yet propagated to the KV storage
// provider we read from (the eventual-consistency window right after
// a deploy). Retry with backoff to give propagation time to complete.
if (attempt < retries) {

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 retries === 0, this branch is skipped on attempt 0 and the null asset read with LONG_CACHE_TTL on line 66 is returned/cached for a year. The old code always avoided caching a null long-term. To preserve that guarantee without penalising the successful-hit fast path, re-read once with the short TTL when a long-TTL read returned null and no retries remain:

Suggested change
if (attempt < retries) {
if (attempt < retries) {
continue;
}
// Out of retries. If this null came from a long-TTL read (e.g.
// retries === 0), re-read once with the short TTL so a transient
// null is never cached for the long TTL.
if (!isRetry) {
return assetsKVNamespace.getWithMetadata<AssetMetadata>(assetKey, {
type: "stream",
cacheTtl: SHORT_CACHE_TTL,
});
}
return asset;

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #14649.

I reviewed the PR and posted a single batch review with one actionable issue:

Issue (medium): The retries = 0 case regresses the "never cache a null for a year" guarantee. The old code always re-read a null value with the short 60s TTL regardless of retry count; the new loop uses LONG_CACHE_TTL on attempt 0 and, when retries === 0, returns that long-TTL null without re-reading. I posted a suggestion comment to re-read once with the short TTL when a non-retry read returns null and no retries remain, which preserves the guarantee without penalizing the successful-hit fast path.

The retry-on-null logic, backoff/jitter, short-TTL usage on retries, sentry logging, and test coverage otherwise look correct.

github run

@devin-ai-integration devin-ai-integration 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@14649

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14649

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14649

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14649

miniflare

npm i https://pkg.pr.new/miniflare@14649

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14649

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14649

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14649

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14649

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14649

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14649

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14649

wrangler

npm i https://pkg.pr.new/wrangler@14649

commit: 13af4c2

@WillTaylorDev

Copy link
Copy Markdown
Contributor

I would be interested in pulling KV into reviewing this. I don't think we should need retry at all. We do have guarantee that assets are written to at least one store, and we have some special guarantees that KV will attempt to reach from BOTH before returning a 404.. at least last I looked 😓

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

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

3 participants