Published: July 31, 2026 · by Srinu Desetti (creator & maintainer)
valkey-errors: A Shared Error Model for Valkey Node.js Clients
valkey-errors is the single source of truth for failure semantics across the Valkey Node.js stack. Every parser, client, and adapter throws these five classes — so every consumer can identify failures reliably with instanceof, not string matching. I created and maintain this package as the foundation layer of the Valkey client stack.
| Package | valkey-errors (opens in a new tab) v1.0.1 |
| License | MIT |
| Runtime dependencies | 0 |
| Lines (modern build) | 59 |
| Node.js | ≥ 4 |
| TypeScript declarations | Included |
| Source | github.com/webdevelopersrinu/valkey-errors (opens in a new tab) |
The Problem: Failure Without a Shared Vocabulary
A Valkey client stack is layered: a low-level protocol parser, a connection-managing client, and application code on top. All three layers need to signal — and react to — failures with very different meanings: the server refused the command, the protocol byte stream is corrupt, the command never received an answer.
Without a shared error vocabulary, two real problems appear:
- Consumers can't distinguish failure types. A generic
Error("WRONGTYPE …")forces application code to string-match on messages, which is fragile and breaks across server versions and locales. instanceofbreaks across packages. If the parser and the client each defined their ownReplyError, they would be different classes —err instanceof ReplyErrorwould fail even when the error "looks" right.
That is the entire purpose of valkey-errors: one tiny, dependency-free package that defines failure identity once, so the parser throws the exact same class the client re-exports and the application checks against.
The value is not in the code volume — it is in where those classes live: a single package at the bottom of the dependency graph.
Class Hierarchy: Five Classes, One Family
All classes descend from ValkeyError, so a single err instanceof ValkeyError check separates "a Valkey-stack failure" from a bug in your own code (a TypeError, for instance) that should surface loudly instead of being swallowed.
Error ← JavaScript built-in
└── ValkeyError ← base class — catch-all for the stack
├── ReplyError ← server error reply (RESP `-`)
├── ParserError ← protocol parse failure · carries buffer, offset
└── AbortError ← command aborted before an answer
└── InterruptError ← aborted by a deliberate interruptBecause InterruptError extends AbortError, any handler written for AbortError covers both; code that cares about why the abort happened can narrow further.
Where Errors Originate
Each error class is born in a specific layer of the stack and propagates upward. Protocol-level errors originate in the parser; lifecycle errors originate in the client. The application only ever catches.
| Layer | Role |
|---|---|
| Application | Catches everything — routes by instanceof |
| Client | Throws AbortError, InterruptError — connection lifecycle failures |
| Parser | Throws ReplyError, ParserError — protocol-level failures |
| Valkey Server | Replies over RESP — including -ERR error replies |
Class Reference
ValkeyError — the catch-all boundary
Rarely thrown directly — it is the parent of all other classes. Its job is to separate stack failures from bugs in your own code:
catch (err) {
if (err instanceof ValkeyError) handleValkeyFailure(err)
else throw err // a bug in our code — let it surface
}ReplyError — routine, handle in app logic
Thrown by the parser when the server's reply begins with - (a RESP error reply). The server received the command, understood it, and deliberately refused. Connection and protocol are healthy. This is by far the most common error class.
Clients decorate it with err.command, err.args, and err.code so handlers know which call failed.
await client.incr('username')
// ReplyError: WRONGTYPE Operation against a key holding the wrong kind of value
await client.foobar()
// ReplyError: ERR unknown command 'foobar'ParserError — severe, reconnect
Thrown by the parser when incoming bytes do not match the RESP protocol at all. The stream is corrupt or desynchronized — every subsequent reply on this connection is suspect. The correct reaction: destroy the connection and reconnect. Client libraries typically handle this internally; applications rarely see it.
The debug payload makes protocol incidents diagnosable after the fact: err.buffer (the raw bytes under parse) and err.offset (the exact failure position) travel with the error itself.
AbortError — ambiguous, retry with care
Thrown by the client for commands still queued when the connection drops or the client shuts down. No answer ever arrived — the server may or may not have executed the command. Safe to retry idempotent commands (GET, SET); non-idempotent ones (INCR) need care.
client.set('a', '1') // sent, awaiting reply…
client.quit() // connection closes first
// → pending SET rejects with AbortErrorInterruptError — expected, usually deliberate
Thrown by the client when pending commands are force-flushed — e.g. a forced end(true). Same situation as AbortError (no answer), tagged with why: an intentional interrupt, not an accidental disconnect. Handlers for AbortError cover it automatically. Carries err.origin — the underlying error that triggered the interrupt.
Quick Reference: Decision Table
| Class | Origin | Meaning | Typical reaction |
|---|---|---|---|
ReplyError | Parser (server reply) | Server refused the command | Handle in application logic |
ParserError | Parser | Protocol stream is corrupt | Reconnect |
AbortError | Client | Connection died with commands pending | Retry if idempotent |
InterruptError | Client | Pending commands force-flushed deliberately | Usually expected — ignore |
ValkeyError | — (base class) | Any Valkey-stack failure | Catch-all boundary |
Design Notes — Decisions I Kept and Why
Cheap error construction on the hot path
Server error replies are routine — every failed transaction, every wrong-type operation. A busy client can construct thousands of ReplyErrors per second, and capturing a full stack trace is one of the most expensive single operations in Node.js. The ReplyError and ParserError constructors therefore temporarily cap Error.stackTraceLimit at 2 while calling super(), then restore it — short, cheap traces on the hot path without affecting stack depth anywhere else.
Dual builds for legacy runtimes
The entry point (index.js) selects an implementation at load time: lib/modern.js (ES2015 classes) on Node.js ≥ 7, or lib/old.js (prototype-based, functionally identical) on older runtimes. Consumers never see the split.
The contract is enforced by tests, not convention
The mocha suite pins the parts other packages depend on: the full inheritance chain (interruptError instanceof AbortError, … instanceof ValkeyError, … instanceof Error), the buffer/offset payload on ParserError, and — notably — that the first stack frame is the throw site itself. That last assertion means the stack-trimming optimization can never silently regress into eating the one frame that matters.
Provenance
valkey-errors is a Valkey-native port of redis-errors (© 2017 Ruben Bridgewater, MIT) — same classes, same behavior, with ValkeyError replacing RedisError. Behavior parity is deliberate: it keeps ports of existing Redis client libraries to the Valkey ecosystem mechanical, while removing the Redis branding and giving the Valkey ecosystem control over publishing and maintenance.
This is a solo project — I designed, built, and maintain valkey-errors independently as the foundation layer of the Valkey Node.js client stack.
What's Next?
valkey-errors sits at the bottom of the stack. The next layer up consumes it directly: the wire-protocol decoder that turns raw TCP bytes into JavaScript values — and throws these exact classes when things go wrong.
← Back to Open Source overview
Continue to valkey-parser — the wire-protocol decoder →
Frequently Asked Questions
Why not just depend on redis-errors directly?
Functionally you could — behavior is identical by design. But the Valkey ecosystem should not have its core error identity owned by a Redis-branded package it doesn't control: naming (RedisError at the root of a Valkey stack), publishing cadence, and maintenance would all sit outside the project. The port keeps behavior parity 1:1, so migrating an existing Redis client library remains a mechanical rename.
Isn't five classes too small to be a package?
The product is not the code — it is the shared class identity. instanceof only works if every layer resolves to the same class object, which requires a single package at the bottom of the dependency graph. Small is a feature here: zero dependencies, no transitive surface, nothing to audit beyond 59 lines. The upstream redis-errors made the same call and sits under some of the most-downloaded packages on npm.
Why cap stack traces at 2 frames?
Because ReplyError is constructed on the hot path — every failed EXEC, every wrong-type operation — and stack capture is one of the most expensive single operations in Node.js. Two frames keep the throw site (the only frame that matters for a server-refusal) while making construction cheap enough for thousands per second. The limit is saved and restored around super(), so global stack depth is untouched, and the test suite asserts the throw site survives.
Why keep a pre-ES2015 fallback in 2026?
Parity with upstream, at zero cost. The runtime check is two character comparisons at module load; the legacy build is never even required on modern Node. It widens engines to >=4 for embedded and long-tail environments without complicating the modern path. Dropping it is a one-line change whenever the ecosystem decides to.