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
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 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.

Above EncryptionError sit four tiered bases you can catch when you want a whole category: CryptoError, SessionError, ProtocolError, and ClientError.

The code taxonomy

Session

CodeMeaningWhat it implies
SESSION_NOT_FOUNDNo session exists for the addressEstablish one
SESSION_CORRUPTEDSession data is invalidDelete and re-establish
SESSION_CONFLICTMultiple active sessions detectedSesame convergence, not a retry

Message

CodeMeaningWhat it implies
INVALID_CIPHERTEXTMalformed message formatRequest retransmission
DECRYPTION_FAILEDCould not decryptInspect session state
ENCRYPTION_FAILEDCould not encryptInspect session state
MESSAGE_DUPLICATEAlready processedIgnore; replay protection working
MESSAGE_TOO_OLDMessage keys expiredUnrecoverable for that message
TOO_MANY_SKIPPED_MESSAGESSkip bound exceededReset session
INVALID_MESSAGE_VERSIONProtocol version mismatchSee 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.

ClassGuardExtra fields
UntrustedIdentityErrorisUntrustedIdentityError()
IdentityKeyChangedErrorisIdentityKeyChangedError().changedAddress, .oldIdentityKey, .newIdentityKey
SessionConflictErrorisSessionConflictError()
RegistrationIdChangedErrorisRegistrationIdChangedError().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. 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 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

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

Next

On this page