netlify / netlify/primitives

@netlify/blobs: conditional writes report { modified: true } for every non-412 failure (silent lost write)

Open Beginner friendly
#741 0 comments 0 reactions 0 assignees View on GitHub

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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.