Error handling and retries
The full error taxonomy, which failures are retryable, and how to recover a session without making ratchet state worse.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 12 min
Encrypted messaging fails differently from the rest of your application. You can repeat a failed HTTP call. You usually cannot repeat a failed decrypt because the attempt advances a state machine. This page covers the taxonomy the SDK throws, which parts of it are worth retrying, and what recovery actually means.
The base class
Every error the SDK raises extends EncryptionError, which extends Error and adds three fields:
.code: a stableEncryptionErrorCodevalue. This is the field you branch on..context: structured context about the operation. Safe to log after redaction. See observability..originalError: the underlying cause, if a wrapper supplied it.
Above EncryptionError sit four tiered bases you can catch when you want a whole category: CryptoError, SessionError, ProtocolError, and ClientError.
The code taxonomy
Session
| Code | Meaning | What it implies |
|---|---|---|
SESSION_NOT_FOUND | No session exists for the address | Establish one |
SESSION_CORRUPTED | Session data is invalid | Delete and re-establish |
SESSION_CONFLICT | Multiple active sessions detected | Sesame convergence, not a retry |
Message
| Code | Meaning | What it implies |
|---|---|---|
INVALID_CIPHERTEXT | Malformed message format | Request retransmission |
DECRYPTION_FAILED | Could not decrypt | Inspect session state |
ENCRYPTION_FAILED | Could not encrypt | Inspect session state |
MESSAGE_DUPLICATE | Already processed | Ignore; replay protection working |
MESSAGE_TOO_OLD | Message keys expired | Unrecoverable for that message |
TOO_MANY_SKIPPED_MESSAGES | Skip bound exceeded | Reset session |
INVALID_MESSAGE_VERSION | Protocol version mismatch | See upgrades |
Identity and trust
UNTRUSTED_IDENTITY, IDENTITY_KEY_CHANGED, SIGNATURE_VERIFICATION_FAILED. These are the codes that require a human, not a handler.
Prekey and establishment
INVALID_PREKEY_BUNDLE, PREKEY_NOT_FOUND, INVALID_REGISTRATION_ID, REGISTRATION_ID_CHANGED.
Ratchet
RATCHET_ERROR, INVALID_DH_KEY.
Storage
KEY_STORAGE_ERROR, DATABASE_ERROR, DATABASE_LOCKED. A locked database usually means the wrong encryption key, not contention.
Cryptographic
KYBER_ERROR, KDF_ERROR, HMAC_VERIFICATION_FAILED.
Specialised classes and their guards
Use the exported type guards rather than instanceof. Guards survive bundling and duplicated module instances. instanceof does not.
| Class | Guard | Extra fields |
|---|---|---|
UntrustedIdentityError | isUntrustedIdentityError() | — |
IdentityKeyChangedError | isIdentityKeyChangedError() | .changedAddress, .oldIdentityKey, .newIdentityKey |
SessionConflictError | isSessionConflictError() | — |
RegistrationIdChangedError | isRegistrationIdChangedError() | .resetAddress |
DuplicatedMessageError | — | — |
SealedSenderAuthError | — | — |
PQXDHRequiredError | — | — |
PQXDHRequiredError is what a peer with no post-quantum material looks like under the default postQuantum: 'required' policy. It is a fail-closed outcome, not a fault. See upgrades.
Identify a "Bad MAC" failure
There is no class named BadMacError in this SDK. The classic libsignal "Bad MAC" failure surfaces as HMAC_VERIFICATION_FAILED ("message tampered") and/or DECRYPTION_FAILED. If you search your logs for BadMac, you will find nothing and conclude the problem is elsewhere. Search for the codes.
Dispatching
import { EncryptionError, EncryptionErrorCode } from '@open-e2ee/signal-protocol-sdk';
// The narrowing guards live on the /types subpath, not the package root.
import {
isUntrustedIdentityError,
isIdentityKeyChangedError,
isSessionConflictError,
isRegistrationIdChangedError,
} from '@open-e2ee/signal-protocol-sdk/types';
async function handleEncryptionError(client, remoteAddress: string, error: unknown) {
if (!(error instanceof EncryptionError)) throw error;
// 1. Human trust decisions. Never automatic.
if (isIdentityKeyChangedError(error)) {
await ui.showIdentityChanged({
userId: error.changedAddress.userId,
deviceId: error.changedAddress.deviceId,
oldKey: error.oldIdentityKey,
newKey: error.newIdentityKey,
});
return;
}
if (isUntrustedIdentityError(error)) {
const safetyNumber = await client.verify(remoteAddress);
await ui.showVerificationDialog(safetyNumber);
return;
}
// 2. Structural session problems with a defined move.
if (isSessionConflictError(error)) {
await client.archiveSession(remoteAddress);
return;
}
if (isRegistrationIdChangedError(error)) {
await client.archiveSession(remoteAddress);
await ui.showSessionReset(error.resetAddress.userId);
return;
}
// 3. Codes.
switch (error.code) {
case EncryptionErrorCode.MESSAGE_DUPLICATE:
return; // replay protection did its job
case EncryptionErrorCode.SESSION_CORRUPTED:
await client.deleteSession(remoteAddress);
return;
case EncryptionErrorCode.MESSAGE_TOO_OLD:
case EncryptionErrorCode.TOO_MANY_SKIPPED_MESSAGES:
await ui.showMessageUnavailable(remoteAddress);
return;
case EncryptionErrorCode.HMAC_VERIFICATION_FAILED:
case EncryptionErrorCode.DECRYPTION_FAILED:
metrics.increment('signal.decrypt.failed', { code: error.code });
return;
default:
client.logger.error?.('Unhandled Signal Protocol error', error, { code: error.code });
}
}Note the ordering. Specialised guards run before the code switch because an IdentityKeyChangedError also carries a code. A generic branch would otherwise swallow it.
What to retry, and what never to retry
/utils/retry exports withRetry(fn, options) with operationName, maxRetries, baseDelay, maxDelay, and enableJitter. It also exports isRetryableError(error), which encodes the SDK's own view.
import { withRetry } from '@open-e2ee/signal-protocol-sdk/utils/retry';
await withRetry(() => client.syncToServer(), {
operationName: 'syncToServer',
maxRetries: 2,
baseDelay: 2000,
maxDelay: 30000,
});Retryable. Network and timeout failures, database lock and busy conditions, and transient race conditions. These are I/O problems wearing a crypto costume. syncToServer(), rotateEcSignedPreKey(), rotateKyberPreKey(), and prekey bundle fetches belong here.
Explicitly non-retryable. The SDK treats these codes as terminal and will not retry them: INVALID_PREKEY_BUNDLE, SESSION_CONFLICT, ENCRYPTION_FAILED, TOO_MANY_SKIPPED_MESSAGES, IDENTITY_KEY_CHANGED.
Never wrap a decrypt in a retry. This is the single most common mistake. Retrying decryptMessage(...) does not help. The applicable message key was already consumed or was never derivable. Each attempt re-enters a state machine that moves forward.
Repeated attempts against a damaged session consume the skipped-key budget. They can leave ratchet state less recoverable than before. If a decrypt fails, route to recovery. Do not try again.
Never retry an identity change. IDENTITY_KEY_CHANGED means the peer's pinned tuple no longer matches. That is a trust decision belonging to a person. Automating it converts a security control into a notification.
Session recovery
Four operations, in ascending order of destruction:
hasSession(remoteAddress): cheap existence check before you decide anything.getSessionHealth(userId): diagnostics. Returnsstatus,sessionExists,issues[], akeyStatusblock (hasIdentityKey,hasSignedPreKey,hasKyberPreKey,signedPreKeyAgeDays,kyberPreKeyAgeDays,needsRotation), and, when a session exists,sessionStatuswithageDays,messagesSent,messagesReceived,isExpiredForSending, andisExpiredForReceiving.archiveSession(remoteAddress): moves the current session to the inactive list. This preserves it for delayed message decryption. Per SESAME §3.2 the "previously active session is moved to the head of the inactive sessions list." Use this operation for a conflict or registration-ID change. Messages encrypted to the old session can still arrive.deleteSession(remoteAddress): destroys it. Anything still in flight under that session becomes undecryptable.
forceCompleteKeyReset() sits outside this ladder. It returns a ForceKeyResetResult counting deletedSessions and deletedPreKeys, and the SDK documents it as a development and debugging operation. If production code calls it, the product lacks a recovery path. The call is not itself a recovery path.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
EncryptionError.code | yes | no | no | safe to export to telemetry |
EncryptionError.context | yes | no | no | export only after redaction |
.oldIdentityKey / .newIdentityKey | yes | no | no | public halves; never log |
.changedAddress | yes | no | no | identifies a peer; treat as PII |
| Failed ciphertext | yes | already at relay | no | envelope size |
| Message plaintext | yes | no | no | no |
Session record (version: 4) | yes | no | no | no |