@netlify/blobs: conditional writes report { modified: true } for every non-412 failure (silent lost write)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 26
- Forks
- 21
- Avg merge
- 22h 38m
- Merged PRs (30d)
- 13
Description
A conditional write that fails with any non-412 status — including a 503 after
all internal retries are exhausted — returns { etag: '', modified: true }
instead of throwing. The caller is told its write landed when nothing was
written: a silent lost write for every consumer using conditional writes for
optimistic concurrency.
Where
packages/blobs, in the store's write path (verbatim in the published bundle —
dist/main.cjs:610 and :651 at 10.7.12):
if (conditions) {
return res.status === STATUS_PRE_CONDITION_FAILED ? { modified: false } : { etag, modified: true };
}
if (res.status === STATUS_OK) {
...
The conditions branch returns before the STATUS_OK check, and nothing on
that path throws. So for a conditional write (onlyIfNew / onlyIfMatch) the
mapping is:
| Response status | Reported result |
|---|---|
| 200 | { etag, modified: true } ✅ |
| 412 | { modified: false } ✅ |
| anything else (500, 502, 503, …) | { etag: '', modified: true } ❌ |
Unconditional writes on the same path do reach the status check and surface a
BlobsInternalError — only the conditional form swallows the failure.
Reproduction
Verified against the stock SDK with no wrapper involved (first measured on
10.0.4, 2026-08-11; the code path is unchanged at 10.7.12). Injected fetch
answers 503 on every attempt, so the SDK's internal retries all fail:
import { getStore } from '@netlify/blobs';
const store = getStore({
name: 'repro',
siteID: '<site>',
token: '<token>',
fetch: async () => new Response('Service Unavailable', { status: 503 }),
});
const result = await store.setJSON('key', { hello: 'world' }, { onlyIfMatch: 'some-etag' });
console.log(result); // { etag: '', modified: true } — reported as a successful write
Expected: a thrown BlobsInternalError (matching the unconditional write
behavior), or at minimum a result shape that distinguishes "precondition held
and the write landed" from "the request failed".
Why this matters
Conditional writes exist for compare-and-swap patterns, where the return value
is the only signal the caller has. In our case (a CAS store whose HEAD pointer
is swapped with onlyIfMatch), a Blobs outage mid-publish makes the publish
path report success for a version no reader will ever see. No status code the
transport returns can fix it from the outside: everything except 412 is
reported as success, and a synthetic 412 would be a different lie ("someone
else changed it").
Workaround we shipped
Treat a claimed conditional-write success with etag === '' as a failure (a
real provider success always carries an etag), corroborated by tracking what
the injected transport gave up on. That detects the phantom success, but every
consumer of the documented API shape is exposed until the SDK reports the
failure itself.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/blobs and trace the store write path corresponding to dist/main.cjs lines 610 and 651, focusing on the conditional-write branch before the STATUS_OK check. Reproduce the 503 case with the injected fetch shown in the issue, then verify that 200 and 412 retain their documented results while other failures surface as errors rather than successful writes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100