Error handling and retries
The full error taxonomy, which failures are retryable, and how to recover a session without making ratchet state worse.
- Status
- stable
- Applies to
- 6.0.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 in the EncryptionErrorCode family extends EncryptionError. The
class 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.
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 |
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 | No retained key exists below the current counter | Discard; the key was consumed or expired |
TOO_MANY_SKIPPED_MESSAGES | Skip bound exceeded | Reset session |
Identity and trust
UNTRUSTED_IDENTITY requires an explicit trust decision when a candidate no
longer matches the pinned tuple. IDENTITY_MISMATCH and
SIGNATURE_VERIFICATION_FAILED are integrity failures. Reject their input and
inspect the operation.
Prekey and establishment
INVALID_PREKEY_BUNDLE, PREKEY_NOT_FOUND, PREKEY_ROTATION_REQUIRED.
Ratchet
COUNTER_OVERFLOW, INVALID_DH_KEY.
Storage
KEY_STORAGE_ERROR wraps a key-storage failure. STORAGE_QUOTA_EXCEEDED means
that the storage origin has no quota for the rejected write. Inspect the first
error's cause. Free space before retrying the second.
Specialized classes and their guards
Use the exported type guards to centralize narrowing and keep call sites stable.
The current guards delegate to instanceof, so they require one loaded copy of
the SDK. Do not bundle duplicate package instances.
| Class | Guard | Extra fields |
|---|---|---|
UntrustedIdentityError | isUntrustedIdentityError() | .untrustedAddress, .identity |
DuplicatedMessageError | isDuplicatedMessageError() | .duplicatedAddress, .counter?, .epoch?, .fingerprintPreview? |
SealedSenderAuthError | isSealedSenderAuthError() | — |
PQXDHRequiredError | isPQXDHRequiredError() | .remoteAddress, .reason, .retryable, .suggestedRetryDelay?, .fallbackType |
StorageQuotaExceededError | isStorageQuotaExceededError() | — |
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 DECRYPTION_FAILED. If you search your logs for BadMac, you will find nothing and conclude the problem is elsewhere. Search for the code.
Dispatching
import {
EncryptionError,
EncryptionErrorCode,
ProtocolAddress,
isUntrustedIdentityError,
} from '@open-e2ee/signal-protocol-sdk';
// The remaining narrowing guards live on the /types subpath, not the package root.
import {
isDuplicatedMessageError,
isPQXDHRequiredError,
isStorageQuotaExceededError,
} from '@open-e2ee/signal-protocol-sdk/types';
async function handleEncryptionError(client, remoteAddress: ProtocolAddress, error: unknown) {
if (!(error instanceof EncryptionError)) throw error;
// 1. Human trust decisions. Never automatic.
if (isUntrustedIdentityError(error)) {
await ui.showIdentityChanged({
userId: error.untrustedAddress.userId,
deviceId: error.untrustedAddress.deviceId,
identity: error.identity,
});
return;
}
// 2. Outcomes with a defined move that is not an error to the user.
if (isDuplicatedMessageError(error)) {
return; // consumed or expired skipped key
}
if (isPQXDHRequiredError(error)) {
await ui.showPeerCannotReceive(error.remoteAddress, error.reason);
return;
}
if (isStorageQuotaExceededError(error)) {
await storage.freeSpaceThenRetry();
return;
}
// 3. Codes.
switch (error.code) {
case EncryptionErrorCode.MESSAGE_DUPLICATE:
return; // group paths can raise the base class with this code
case EncryptionErrorCode.SESSION_CORRUPTED:
await client.deleteSession(remoteAddress);
return;
case EncryptionErrorCode.TOO_MANY_SKIPPED_MESSAGES:
await ui.showMessageUnavailable(remoteAddress);
return;
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. Specialized guards run before the code switch because an UntrustedIdentityError 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(), rotatePreKeys(), and prekey bundle fetches belong here.
Retried once by the SDK. A user-visible or background-sync send can receive 503 DELIVERY_UNCERTAIN from the Relay: the delivery was neither confirmed nor refused. The SDK posts the identical request one more time under the same message identifier before it throws. When the error still reaches your code, isOutgoingMessageError(error) gives you error.clientMessageId. Call send() again with that identifier and the SDK replays the stored ciphertext. Never send the same content under a new identifier, and never change the request under a reused one: the Relay answers 409 RETRY_CONFLICT, which is terminal. See the Relay delivery table.
Explicitly non-retryable. The SDK treats eight codes as terminal and will not retry them: INVALID_PREKEY_BUNDLE, ENCRYPTION_FAILED, TOO_MANY_SKIPPED_MESSAGES, KEY_STORAGE_ERROR, UNTRUSTED_IDENTITY, SIGNATURE_VERIFICATION_FAILED, RECIPIENT_NOT_REGISTERED, and PREKEY_FETCH_RATE_LIMITED.
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. UNTRUSTED_IDENTITY means the candidate no
longer matches the peer's pinned tuple. 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 when a peer reinstalls and their session no longer matches. 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 |
.identity | yes | no | no | public halves; never log |
.untrustedAddress | 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 |