OpenE2EE

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 stable EncryptionErrorCode value. 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

CodeMeaningWhat it implies
SESSION_NOT_FOUNDNo session exists for the addressEstablish one
SESSION_CORRUPTEDSession data is invalidDelete and re-establish

Message

CodeMeaningWhat it implies
INVALID_CIPHERTEXTMalformed message formatRequest retransmission
DECRYPTION_FAILEDCould not decryptInspect session state
ENCRYPTION_FAILEDCould not encryptInspect session state
MESSAGE_DUPLICATENo retained key exists below the current counterDiscard; the key was consumed or expired
TOO_MANY_SKIPPED_MESSAGESSkip bound exceededReset 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.

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

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. Returns status, sessionExists, issues[], a keyStatus block (hasIdentityKey, hasSignedPreKey, hasKyberPreKey, signedPreKeyAgeDays, kyberPreKeyAgeDays, needsRotation), and, when a session exists, sessionStatus with ageDays, messagesSent, messagesReceived, isExpiredForSending, and isExpiredForReceiving.
  • 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

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
EncryptionError.codeyesnonosafe to export to telemetry
EncryptionError.contextyesnonoexport only after redaction
.identityyesnonopublic halves; never log
.untrustedAddressyesnonoidentifies a peer; treat as PII
Failed ciphertextyesalready at relaynoenvelope size
Message plaintextyesnonono
Session record (version: 4)yesnonono

Next

On this page