OpenE2EE

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.

MemberTypeNotes
codeEncryptionErrorCodeAlways present; this is the branch point
contextEncryptionErrorContext | undefinedStructured detail
addressgetterConvenience read of context.address
operationgetterConvenience read of context.operation
originalErrorgetterThe 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

CodeWhat it meansWhat to do
SESSION_NOT_FOUNDNo session with this addressEstablish one, or let send() do it
SESSION_CORRUPTEDStored session record is unusableDelete the session and re-establish
RECIPIENT_NOT_REGISTEREDRecipient has no registered deviceSurface to the user; there is nothing to retry against
PREKEY_FETCH_RATE_LIMITEDRelay throttled the bundle fetchBack off and retry later
SESSION_ESTABLISHMENT_FAILEDHandshake failedInspect originalError; often a bundle or relay problem

Message

CodeWhat it meansWhat to do
INVALID_CIPHERTEXTMalformed or truncated ciphertextDrop it; do not retry
DECRYPTION_FAILEDDecryption did not produce plaintextDrop the message; consider a retry request
ENCRYPTION_FAILEDEncryption failed locallyNot retryable; check session and key state
MESSAGE_DUPLICATENo retained key exists below the current counterDiscard it; the key was consumed or expired
TOO_MANY_SKIPPED_MESSAGESSkip bound exceeded — maxSkip, default 1000Not retryable; the sender is too far ahead or something is wrong
REPLAY_DETECTEDEnvelope and encrypted-content timestamps differDrop it; this is a defense, not a glitch
SENDER_KEY_EXPIREDGroup sender key is past its lifetimeRotate and redistribute

Identity and trust

CodeWhat it meansWhat to do
UNTRUSTED_IDENTITYA candidate differs from the pinned tuple, or verification has no pinned tupleStop; require an explicit trust decision before accepting a changed tuple
IDENTITY_MISMATCHPresented identity does not match the stored recordStop; treat as a trust event
SIGNATURE_VERIFICATION_FAILEDA signature did not verifyReject the material outright

Never auto-accept an identity change. See identity change and safety numbers.

Prekeys and session establishment

CodeWhat it meansWhat to do
INVALID_PREKEY_BUNDLEBundle is malformed or fails verificationNot retryable; a relay or peer bug
PREKEY_NOT_FOUNDRequested prekey is goneRefetch the bundle
PREKEY_ROTATION_REQUIREDLocal prekeys are too old to send withRotate; see maxPreKeyAgeMs

Ratchet

CodeWhat it meansWhat to do
COUNTER_OVERFLOWMessage counter exceeded its boundRe-establish the session
INVALID_DH_KEYRatchet public key rejectedDrop the message

Storage

CodeWhat it meansWhat to do
KEY_STORAGE_ERRORStore failed reading or writing key materialNot retryable; check the adapter
STORAGE_QUOTA_EXCEEDEDThe store refused a write for want of roomFree space or prompt the user; carries StorageQuotaExceededError

Initialization

CodeWhat it meansWhat to do
INITIALIZATION_FAILEDClient creation did not completeDo not use the instance; inspect originalError

Protocol strategy

CodeWhat it meansWhat to do
PQXDH_REQUIREDPost-quantum handshake required but unavailableSee PQXDHRequiredError below; possibly retry later
PQXDH_FAILEDPQXDH handshake failedInspect reason; fails closed rather than downgrading
TRIPLE_RATCHET_REQUIREDTriple Ratchet required but unavailablePeer cannot meet policy

SPQR

CodeWhat it means
SPQR_EPOCH_OUT_OF_RANGEEpoch outside the accepted window
SPQR_MESSAGE_JUMP_TOO_LARGEGap exceeds the bound
SPQR_COUNTER_OVERFLOWCounter bound exceeded
SPQR_INVALID_CIPHERTEXTMalformed SPQR ciphertext
SPQR_VERSION_MISMATCHUnsupported 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

CodeWhat it meansWhat to do
SEALED_SENDER_AUTH_FAILEDSender certificate or access key rejectedFall back to an authenticated send, or refresh the certificate

Generic

CodeWhat it means
INVALID_STATEThe 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.

ClassGuardExtra properties
UntrustedIdentityErrorisUntrustedIdentityError()untrustedAddress, identity
DuplicatedMessageErrorisDuplicatedMessageError()duplicatedAddress, counter?, epoch?, fingerprintPreview?
SealedSenderAuthErrorisSealedSenderAuthError()—
PQXDHRequiredErrorisPQXDHRequiredError()remoteAddress, reason, retryable, suggestedRetryDelay?, fallbackType
StorageQuotaExceededErrorisStorageQuotaExceededError()—

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:

PropertyValues
reason'no_kyber_prekey' | 'pqxdh_failed'
fallbackType'missing_keys' | 'crypto_failure' | 'protocol_mismatch'
retryableboolean
suggestedRetryDelay30000 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.

CodeStatusWhat it meansWhat the SDK doesWhat to do
DELIVERY_UNCERTAIN503The Relay could not confirm that a user-visible or background-sync delivery was recordedPosts the identical request once more, then throwsCall send() again with the same clientMessageId, with backoff; never mint a new identifier for the same content
RETRY_CONFLICT409A repeat of an accepted operation changed its request, for example its delivery classThrows at onceFix 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

FieldDefaultNotes
maxRetries2Retries after the first attempt; total attempts is maxRetries + 1
baseDelay1000 msFirst backoff interval
maxDelay10000 msCeiling on any single delay
enableJittertrueSubtracts 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:

CodeWhy
INVALID_PREKEY_BUNDLEThe bundle is wrong; time will not fix it
ENCRYPTION_FAILEDLocal failure; state must change first
TOO_MANY_SKIPPED_MESSAGESA bound was hit deliberately
KEY_STORAGE_ERRORThe store rejected the operation; the adapter must change first
UNTRUSTED_IDENTITYRetrying past a trust event is the wrong behavior, not a slow one
SIGNATURE_VERIFICATION_FAILEDThe material is rejected; a second attempt rejects it again
RECIPIENT_NOT_REGISTEREDThere is nothing to send to until the recipient registers
PREKEY_FETCH_RATE_LIMITEDThe 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

On this page