Default retry double-applies non-idempotent commands; default auto-pipelining loses read-after-write ordering · Issue #1455 · upstash/redis-js · GitHub
Skip to content

Default retry double-applies non-idempotent commands; default auto-pipelining loses read-after-write ordering #1455

Open

Description

@router0mail

Summary

@upstash/redis sends each Redis command as a JSON POST to the Upstash REST API. Two default-on behaviors break data-integrity expectations the same library does honor via its more explicit API surface:

  1. Retry double-apply. On any transport-level failure the client re-issues the same command (default retry: 5 attempts). For a non-idempotent command (INCR, LPUSH, SETEX, ...) whose effect the server already committed but whose response was lost, the retry applies the operation a second time. One redis.incr("counter") -> the counter is incremented twice.
  2. Auto-pipelining ordering loss. Default enableAutoPipelining: true routes concurrent reads and writes to separate pipelines -> separate HTTP requests. A same-tick Promise.all([redis.set(k,v), redis.get(k)]) has no client-enforced ordering: the server can serve the read before the write, returning a stale value. An explicit redis.pipeline().set(k,v).get(k).exec() sends one ordered request and the read sees the write.

Both are default configuration (no opt-in required) and both are avoidable (retry: false; explicit pipeline), but neither avoidance is documented on the affected command methods.

Finding 1 -- retry loop

pkg/http.ts, request() (lines 261-290):

for (let i = 0; i <= this.retry.attempts; i++) {      // default attempts = 5  -> 6 fetches
  try {
    res = await fetch(requestUrl, requestOptions);
    break;
  } catch (error_) {
    if (i < this.retry.attempts) {
      await new Promise((r) => setTimeout(r, this.retry.backoff(i)));
    }
    // loop re-issues the SAME requestUrl + requestOptions (same body)
  }
}

No idempotency key, no server-side dedup handle. "Response never arrived" includes the case where the server already applied the command and the connection died before the reply -- routine in serverless/edge networks, Upstash's primary deployment target.

PoC: mock endpoint commits an INCR then destroys the socket before responding (first hit only); retry succeeds.

$ node poc-retry-double-apply.mjs
client resolved value : 2
server applied INCR   : 2 time(s)  -> ["INCR counter","INCR counter"]
BUG CONFIRMED: one redis.incr() incremented the counter 2 times.

Finding 2 -- auto-pipelining ordering

pkg/auto-pipeline.ts classifies each command read/write (READ_COMMANDS set) and batches into two independent pipelines, each its own HTTP request:

const commandMode = READ_COMMANDS.has(command) ? "read" : "write";
const activePipeline = isRead ? this.activeReadPipeline : this.activeWritePipeline;

A set and get in the same tick go out as two separate requests with no ordering between them. Contrast pkg/pipeline.ts exec(): one request, server-applied in order.

PoC:

== A) auto-pipelining (DEFAULT)  Promise.all([set(k,"v1"), get(k)]) ==
HTTP requests seen by server : 2
A) get(k) returned: "old"      <-- STALE: read served before write

== B) explicit pipeline  redis.pipeline().set(k,"v1").get(k).exec() ==
HTTP requests seen by server : 1
B) get(k) returned: "v1"       <-- ordered: read sees the write

Impact

Both are default-configuration, silent data-integrity defects, no error surfaced:

  • Finding 1: a counter increments twice, a list item pushed twice, on a routine transport fault.
  • Finding 2: any code doing a set + concurrent get on the same key can observe a stale read under default config while identical logic through an explicit pipeline is correct -- intermittent, hard-to-reproduce class of bug.

Suggested fix

  • Finding 1: idempotency mechanism (server-assigned request id + dedup window), or document per-command that the default is at-least-once, not exactly-once.
  • Finding 2: make auto-pipelining preserve read-after-write ordering for the same key, or document plainly that enableAutoPipelining does not guarantee read-your-writes.

Disclosure note

No SECURITY.md / private vulnerability reporting found on this repo, so filing as a public issue.


This report was produced with AI assistance (Claude, Anthropic), with two live PoCs run against the published @upstash/redis@1.38.4 package (Node 22) against a faithful in-process mock of the Upstash REST protocol, output captured verbatim above.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions