Errors
The complete error taxonomy — base classes, every EncryptionErrorCode, specialized classes with their type guards, and the retry rules.
Applies to @open-e2ee/signal-protocol-sdk 6.0.x. Public APIs and persisted formats follow semantic versioning.
Every error in this taxonomy extends EncryptionError. Branch on error.code,
or use a type guard when you need the extra properties a specialized class
carries. Other SDK modules publish separate error families. For how to structure
handling around this family, see error handling.
Where to import from
Most guards are on /types, not the package root
The package root exports EncryptionError, EncryptionErrorCode, SealedSenderAuthError with isSealedSenderAuthError, and UntrustedIdentityError with isUntrustedIdentityError. An application branches on those errors. Import every other narrowing guard through the /types subpath: isEncryptionError, isDuplicatedMessageError, isPQXDHRequiredError, and isStorageQuotaExceededError.
import { EncryptionErrorCode, isUntrustedIdentityError } from '@open-e2ee/signal-protocol-sdk';
import { isDuplicatedMessageError } from '@open-e2ee/signal-protocol-sdk/types';Importing them from the root fails to resolve. See package subpaths.
There is no BadMacError
If you migrate from libsignal, you probably search for BadMacError. It does not exist in this package: the string "Bad MAC" appears nowhere in the source.
The failure it names surfaces here as EncryptionErrorCode.DECRYPTION_FAILED. Match on that code, not on a class name or a message substring.
The same applies to error-message matching in general. Message text is not API. Codes are. See migrating from libsignal.
EncryptionError
The base class. Each specialized class on this page extends it.
| Member | Type | Notes |
|---|---|---|
code | EncryptionErrorCode | Always present; this is the branch point |
context | EncryptionErrorContext | undefined | Structured detail |
address | getter | Convenience read of context.address |
operation | getter | Convenience read of context.operation |
originalError | getter | The wrapped cause, when the error wraps one |
EncryptionErrorContext carries optional address, operation, identityKey, identity, originalError, and is open to additional keys.
import { EncryptionErrorCode } from '@open-e2ee/signal-protocol-sdk';
// Narrowing guards live on the /types subpath, not the package root.
import { isEncryptionError } from '@open-e2ee/signal-protocol-sdk/types';
try {
await signal.send(recipientId, body);
} catch (error) {
if (!isEncryptionError(error)) throw error;
if (error.code === EncryptionErrorCode.UNTRUSTED_IDENTITY) {
// stop and surface to the user; do not retry
}
}isEncryptionError() is the outermost guard for this family. A rejected value
can be an application error or an error from another SDK family.
EncryptionErrorCode
The full enum, grouped as the source groups it.
Session
| Code | What it means | What to do |
|---|---|---|
SESSION_NOT_FOUND | No session with this address | Establish one, or let send() do it |
SESSION_CORRUPTED | Stored session record is unusable | Delete the session and re-establish |
RECIPIENT_NOT_REGISTERED | Recipient has no registered device | Surface to the user; there is nothing to retry against |
PREKEY_FETCH_RATE_LIMITED | Relay throttled the bundle fetch | Back off and retry later |
SESSION_ESTABLISHMENT_FAILED | Handshake failed | Inspect originalError; often a bundle or relay problem |
Message
| Code | What it means | What to do |
|---|---|---|
INVALID_CIPHERTEXT | Malformed or truncated ciphertext | Drop it; do not retry |
DECRYPTION_FAILED | Decryption did not produce plaintext | Drop the message; consider a retry request |
ENCRYPTION_FAILED | Encryption failed locally | Not retryable; check session and key state |
MESSAGE_DUPLICATE | No retained key exists below the current counter | Discard it; the key was consumed or expired |
TOO_MANY_SKIPPED_MESSAGES | Skip bound exceeded — maxSkip, default 1000 | Not retryable; the sender is too far ahead or something is wrong |
REPLAY_DETECTED | Envelope and encrypted-content timestamps differ | Drop it; this is a defense, not a glitch |
SENDER_KEY_EXPIRED | Group sender key is past its lifetime | Rotate and redistribute |
Identity and trust
| Code | What it means | What to do |
|---|---|---|
UNTRUSTED_IDENTITY | A candidate differs from the pinned tuple, or verification has no pinned tuple | Stop; require an explicit trust decision before accepting a changed tuple |
IDENTITY_MISMATCH | Presented identity does not match the stored record | Stop; treat as a trust event |
SIGNATURE_VERIFICATION_FAILED | A signature did not verify | Reject the material outright |
Never auto-accept an identity change. See identity change and safety numbers.
Prekeys and session establishment
| Code | What it means | What to do |
|---|---|---|
INVALID_PREKEY_BUNDLE | Bundle is malformed or fails verification | Not retryable; a relay or peer bug |
PREKEY_NOT_FOUND | Requested prekey is gone | Refetch the bundle |
PREKEY_ROTATION_REQUIRED | Local prekeys are too old to send with | Rotate; see maxPreKeyAgeMs |
Ratchet
| Code | What it means | What to do |
|---|---|---|
COUNTER_OVERFLOW | Message counter exceeded its bound | Re-establish the session |
INVALID_DH_KEY | Ratchet public key rejected | Drop the message |
Storage
| Code | What it means | What to do |
|---|---|---|
KEY_STORAGE_ERROR | Store failed reading or writing key material | Not retryable; check the adapter |
STORAGE_QUOTA_EXCEEDED | The store refused a write for want of room | Free space or prompt the user; carries StorageQuotaExceededError |
Initialization
| Code | What it means | What to do |
|---|---|---|
INITIALIZATION_FAILED | Client creation did not complete | Do not use the instance; inspect originalError |
Protocol strategy
| Code | What it means | What to do |
|---|---|---|
PQXDH_REQUIRED | Post-quantum handshake required but unavailable | See PQXDHRequiredError below; possibly retry later |
PQXDH_FAILED | PQXDH handshake failed | Inspect reason; fails closed rather than downgrading |
TRIPLE_RATCHET_REQUIRED | Triple Ratchet required but unavailable | Peer cannot meet policy |
SPQR
| Code | What it means |
|---|---|
SPQR_EPOCH_OUT_OF_RANGE | Epoch outside the accepted window |
SPQR_MESSAGE_JUMP_TOO_LARGE | Gap exceeds the bound |
SPQR_COUNTER_OVERFLOW | Counter bound exceeded |
SPQR_INVALID_CIPHERTEXT | Malformed SPQR ciphertext |
SPQR_VERSION_MISMATCH | Unsupported SPQR version |
These are bounds checks in the post-quantum ratchet. They mean that the ratchet deliberately rejected a message. Drop it. If the error recurs, treat it as a signal that the peer states differ.
Sealed sender
| Code | What it means | What to do |
|---|---|---|
SEALED_SENDER_AUTH_FAILED | Sender certificate or access key rejected | Fall back to an authenticated send, or refresh the certificate |
Generic
| Code | What it means |
|---|---|
INVALID_STATE | The operation is not valid in the current state |
Specialized classes
Each carries a type guard and extra properties. Use the guard to centralize
narrowing. The current guards delegate to instanceof, so duplicate SDK
instances remain unsupported.
| Class | Guard | Extra properties |
|---|---|---|
UntrustedIdentityError | isUntrustedIdentityError() | untrustedAddress, identity |
DuplicatedMessageError | isDuplicatedMessageError() | duplicatedAddress, counter?, epoch?, fingerprintPreview? |
SealedSenderAuthError | isSealedSenderAuthError() | — |
PQXDHRequiredError | isPQXDHRequiredError() | remoteAddress, reason, retryable, suggestedRetryDelay?, fallbackType |
StorageQuotaExceededError | isStorageQuotaExceededError() | — |
The specialized classes prefix property names: untrustedAddress, duplicatedAddress, remoteAddress. They have no single error.address beyond the base-class getter that reads context.address.
PQXDHRequiredError detail:
| Property | Values |
|---|---|
reason | 'no_kyber_prekey' | 'pqxdh_failed' |
fallbackType | 'missing_keys' | 'crypto_failure' | 'protocol_mismatch' |
retryable | boolean |
suggestedRetryDelay | 30000 by default when retryable is true |
'no_kyber_prekey' with retryable: true is the common and recoverable case: the peer has not yet uploaded post-quantum key material. Wait suggestedRetryDelay and try again. The SDK fails closed here rather than falling back to a classical handshake: see security and protocol policy.
DuplicatedMessageError is not a fault. Delivery retries produce it. Swallow it.
Relay delivery
A hosted send() can fail after encryption succeeds, when the Relay answers the
delivery request with an error. These answers are not EncryptionError
instances. send() attaches the operation's clientMessageId to whatever it
throws, and isOutgoingMessageError(error) narrows to that shape. A repeat
send() with the same clientMessageId replays the persisted ciphertext under
the same Relay message identifier, so a retry never advances the ratchet twice.
| Code | Status | What it means | What the SDK does | What to do |
|---|---|---|---|---|
DELIVERY_UNCERTAIN | 503 | The Relay could not confirm that a user-visible or background-sync delivery was recorded | Posts the identical request once more, then throws | Call send() again with the same clientMessageId, with backoff; never mint a new identifier for the same content |
RETRY_CONFLICT | 409 | A repeat of an accepted operation changed its request, for example its delivery class | Throws at once | Fix the caller; the first acceptance stands |
The SDK does not retry an ephemeral delivery. Treat an uncertain answer on that
class as a dropped best-effort message.
Retrying
withRetry and isRetryableError come from @open-e2ee/signal-protocol-sdk/utils/retry.
import { withRetry } from '@open-e2ee/signal-protocol-sdk/utils/retry';
const result = await withRetry(() => signal.send(recipientId, body), {
operationName: 'send message',
});SignalProtocolRetryConfig
| Field | Default | Notes |
|---|---|---|
maxRetries | 2 | Retries after the first attempt; total attempts is maxRetries + 1 |
baseDelay | 1000 ms | First backoff interval |
maxDelay | 10000 ms | Ceiling on any single delay |
enableJitter | true | Subtracts up to 25% from each computed delay |
operationName | 'unknown operation' | Appears in log lines only |
Backoff multiplier is 2 and is not configurable.
Never retried
isRetryableError() returns false for exactly these codes:
| Code | Why |
|---|---|
INVALID_PREKEY_BUNDLE | The bundle is wrong; time will not fix it |
ENCRYPTION_FAILED | Local failure; state must change first |
TOO_MANY_SKIPPED_MESSAGES | A bound was hit deliberately |
KEY_STORAGE_ERROR | The store rejected the operation; the adapter must change first |
UNTRUSTED_IDENTITY | Retrying past a trust event is the wrong behavior, not a slow one |
SIGNATURE_VERIFICATION_FAILED | The material is rejected; a second attempt rejects it again |
RECIPIENT_NOT_REGISTERED | There is nothing to send to until the recipient registers |
PREKEY_FETCH_RATE_LIMITED | The relay is refusing on purpose; withRetry would press it |
isRetryableError() returns true for anything it does not recognize, including non-SDK errors. Wrapping an operation in withRetry() therefore retries unknown failures by default. This default can help with transient network faults but can hide programming errors. Scope withRetry() to the calls you intend to retry, not to a whole handler.
Beyond the code list, isRetryableError() also inspects the message text for transient patterns. Network faults: network, fetch, connection, timeout, socket, and the ECONNREFUSED, ENOTFOUND, ENETUNREACH, and ETIMEDOUT codes. Store contention: lock and busy. Races: race, concurrent, and conflict.
Next
- Error handling: the handling patterns these codes feed
- Identity change and safety numbers: the trust-event path
- Offline and reconciliation: retry and delivery in practice
- Migrating from libsignal: name-by-name mapping
Runtime support matrix
Which runtimes the SDK runs in, which store adapter each one uses, and the exact documented position on Expo Go.
Security and protocol policy
Spec revision pins, post-quantum policy defaults, the deliberate divergence from Signal Messenger, the identity trust model, audit status, and the responsibility split.