Errors
The complete error taxonomy — base classes, every EncryptionErrorCode, specialised classes with their type guards, and the retry rules.
Applies to @open-e2ee/signal-protocol-sdk 0.1.x. Public APIs and persisted formats may change before 1.0.
Every error the SDK throws extends EncryptionError. Branch on error.code, or use a type guard when you need the extra properties a specialised class carries. This page is the taxonomy. For how to structure handling around it, see error handling.
Where to import from
Most guards are on /types, not the package root
The package root exports EncryptionError, EncryptionErrorCode, and SealedSenderAuthError with isSealedSenderAuthError. Import every other narrowing guard through the /types subpath: isEncryptionError, isUntrustedIdentityError, isIdentityKeyChangedError, isSessionConflictError, isRegistrationIdChangedError, isDuplicatedMessageError, and isPQXDHRequiredError.
import { EncryptionError, EncryptionErrorCode } from '@open-e2ee/signal-protocol-sdk';
import { isIdentityKeyChangedError } 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.HMAC_VERIFICATION_FAILED. It often surfaces as DECRYPTION_FAILED when the boundary reports the failed decrypt to your code. Match on those codes, 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. Everything else 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.IDENTITY_KEY_CHANGED) {
// stop and surface to the user; do not retry
}
}isEncryptionError() is the outermost guard. Anything it rejects did not come from the SDK.
Tiered base classes
Four intermediate bases group errors by layer. Each carries a narrower code enum and the base EncryptionErrorCode. The tier-specific enum is more precise.
| Class | Guard | Extra members | Its code enum |
|---|---|---|---|
CryptoError | isCryptoError() | cryptoCode | CryptoErrorCode |
SessionError | isSessionError() | sessionCode, sessionId? | SessionErrorCode |
ProtocolError | isProtocolError() | protocolCode, protocolContext? | ProtocolErrorCode |
ClientError | isClientError() | clientCode, userFacingMessage? | ClientErrorCode |
| Enum | Members |
|---|---|
CryptoErrorCode | RNG_FAILURE, ECDH_FAILURE, KYBER_FAILURE, KDF_FAILURE, AES_FAILURE, HMAC_FAILURE, SIGNATURE_FAILURE, HASH_FAILURE, INVALID_KEY, INVALID_CIPHERTEXT |
SessionErrorCode | NOT_FOUND, CORRUPTED, EXPIRED, CONFLICT, INVALID_STATE, RATCHET_FAILED, TOO_MANY_SKIPPED, MESSAGE_KEY_NOT_FOUND, ESTABLISHMENT_FAILED |
ProtocolErrorCode | INVALID_MESSAGE, INVALID_BUNDLE, VERSION_MISMATCH, DEVICE_NOT_FOUND, STALE_DEVICE_LIST, RETRY_REQUIRED, SENDER_KEY_FAILED |
ClientErrorCode | NETWORK_ERROR, VERIFICATION_REQUIRED, RECIPIENT_NOT_FOUND, DELIVERY_FAILED, ENCRYPTION_UNAVAILABLE, SESSION_RESET_NEEDED |
ClientError.userFacingMessage is the only message in the taxonomy intended for display. Every other message is for logs.
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 |
SESSION_CONFLICT | Two session establishments raced | Not retryable; resolve, then 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 | Already-processed message | Ignore — this is a normal, safe outcome |
MESSAGE_TOO_OLD | Outside the accepted age window | Drop it |
TOO_MANY_SKIPPED_MESSAGES | Skip bound exceeded — maxSkip, default 1000 | Not retryable; the sender is too far ahead or something is wrong |
REPLAY_DETECTED | A message key was presented twice | Drop it; this is a defence, not a glitch |
INVALID_MESSAGE_VERSION | Unsupported message format version | Peer is on an incompatible build |
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 | Identity is not trusted for this operation | Stop; require an explicit user trust decision |
IDENTITY_KEY_CHANGED | Peer's identity key changed | Not retryable; show a safety-number warning and require acceptance |
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 |
INVALID_REGISTRATION_ID | Registration id outside the valid range | Reject |
REGISTRATION_ID_CHANGED | Peer reinstalled or reset | Treat as a session reset event |
Ratchet
| Code | What it means | What to do |
|---|---|---|
RATCHET_ERROR | Ratchet step failed | Inspect context; usually indicates state divergence |
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 | Retryable in principle; check the adapter |
DATABASE_ERROR | Underlying database error | Check originalError |
DATABASE_LOCKED | Contention on the store | Retryable; withRetry() handles it |
Initialization
| Code | What it means | What to do |
|---|---|---|
INITIALIZATION_FAILED | Client creation did not complete | Do not use the instance; inspect originalError |
IDENTITY_KEY_ERROR | Local identity key could not be loaded or generated | Storage or bootstrap-secret problem |
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_EPOCH_REGRESSION | Epoch moved backwards |
SPQR_MESSAGE_JUMP_TOO_LARGE | Gap exceeds the bound |
SPQR_KEY_ALREADY_USED | Key reuse detected |
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.
Cryptographic
| Code | What it means | What to do |
|---|---|---|
KYBER_ERROR | ML-KEM operation failed | Inspect originalError |
KDF_ERROR | Key derivation failed | Inspect originalError |
HMAC_VERIFICATION_FAILED | Authentication tag did not verify | This is the "Bad MAC" case; drop the message |
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 |
UNKNOWN_ERROR | Unclassified failure |
Specialised classes
Each carries a type guard and extra properties. Use the guard: instanceof across bundle boundaries is not reliable.
| Class | Guard | Extra properties |
|---|---|---|
UntrustedIdentityError | isUntrustedIdentityError() | untrustedAddress, identity |
IdentityKeyChangedError | isIdentityKeyChangedError() | changedAddress, oldIdentityKey, newIdentityKey |
SessionConflictError | isSessionConflictError() | conflictAddress |
RegistrationIdChangedError | isRegistrationIdChangedError() | resetAddress, oldRegistrationId, newRegistrationId |
DuplicatedMessageError | isDuplicatedMessageError() | duplicatedAddress, counter?, epoch?, fingerprintPreview? |
SealedSenderAuthError | isSealedSenderAuthError() | — |
PQXDHRequiredError | isPQXDHRequiredError() | remoteAddress, reason, retryable, suggestedRetryDelay?, fallbackType |
The specialised classes prefix property names: untrustedAddress, changedAddress, conflictAddress, resetAddress, duplicatedAddress. 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.
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 |
SESSION_CONFLICT | Retrying compounds the conflict |
ENCRYPTION_FAILED | Local failure; state must change first |
TOO_MANY_SKIPPED_MESSAGES | A bound was hit deliberately |
IDENTITY_KEY_CHANGED | Retrying past a trust event is the wrong behaviour, not a slow one |
isRetryableError() returns true for anything it does not recognise, 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 for transient patterns: network, fetch, connection, timeout, socket. Lock and busy. 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.