Complete API reference for the Katzenpost thin client libraries (Go, Rust, Python)

Thin Client API Reference

This is the complete API reference for the Katzenpost thin client. The thin client is an interface to the kpclientd daemon, which handles all cryptographic and network operations. The thin client communicates with the daemon over a local socket using CBOR-encoded messages.

This document is generated. The canonical source is website/tools/thin-client-api-gen/; edit binding docstrings (in the source trees) or groups.yaml / overlay/*.md (in the generator) — do not edit this file directly, as local changes will be overwritten by the next generation pass.

There are three implementations: a Go reference (katzenpost/client/thin), a Rust binding (thin_client/src), and a Python binding (thin_client/katzenpost_thinclient).

For pinned versions of the full stack (including kpclientd, katzenqt, and the server-side components), see Build from source.

For conceptual background on Pigeonhole, see Understanding Pigeonhole. For task-oriented usage guides, see Thin Client How-to Guide.


Configuration and Construction

The thin client is configured via a TOML file that specifies the daemon socket path and network geometry. We usually name this configuration file thinclient.toml.

cfg, err := thin.LoadFile("thinclient.toml")
if err != nil {
    log.Fatal(err)
}

logging := &config.Logging{Level: "INFO"}
client := thin.NewThinClient(cfg, logging)
let config = Config::new("thinclient.toml")?;
let client = ThinClient::new(config).await?;
config = Config("thinclient.toml")
client = ThinClient(config)

Connection Management

Dial / new / start

Connect to the kpclientd daemon. The Rust binding connects during construction (ThinClient::new), whereas Go and Python construct the client first and then connect via Dial() / start().

func (t *ThinClient) Dial() error
pub async fn new(config: Config) -> Result<Arc<Self>, Box<dyn std::error::Error>>
async def start(self, loop: asyncio.AbstractEventLoop) -> None:

Close / stop

Disconnect from the daemon and shut down the thin client. All blocked callers receive an error.

func (t *ThinClient) Close() error
pub async fn stop(&self)
def stop(self) -> None:

IsConnected / is_connected

Returns whether the daemon is currently connected to the mixnet.

func (t *ThinClient) IsConnected() bool
pub fn is_connected(&self) -> bool
def is_connected(self) -> bool:

Disconnect / disconnect

Disconnect from the daemon without shutting down. The thin client will automatically reconnect with exponential backoff.

func (t *ThinClient) Disconnect() error
pub async fn disconnect(&self)
def disconnect(self) -> None:

Events

The thin client emits events for connection status changes, PKI document updates, and message replies. Go uses an event channel; Rust uses a broadcast receiver; Python uses async callbacks supplied to the Config constructor.

// Get a channel that receives all events
eventCh := client.EventSink()
defer client.StopEventSink(eventCh)

for ev := range eventCh {
    switch ev.(type) {
    case *thin.ConnectionStatusEvent:
        // ...
    case *thin.NewDocumentEvent:
        // ...
    case *thin.MessageReplyEvent:
        // ...
    }
}
// Get a receiver that yields all events as CBOR BTreeMaps
let mut event_rx = client.event_sink();

tokio::spawn(async move {
    while let Some(event) = event_rx.recv().await {
        // Inspect event["type"] and dispatch
    }
});
# Pass async callback functions to the Config constructor.
# Each callback receives a dict with event-specific keys.
# All callbacks are optional — omitted events are ignored.

async def on_connection_status(event):
    print(f"Connected: {event['is_connected']}")

async def on_message_reply(event):
    print(f"Reply for SURBID {event['surbid']!r}: {event['payload']!r}")

config = Config(
    "thinclient.toml",
    on_connection_status=on_connection_status,
    on_message_reply=on_message_reply,
)
client = ThinClient(config)

Event types

  • ConnectionStatusEvent — emitted when the daemon’s connection to the mixnet changes. Fields (Go): IsConnected bool, Err error, InstanceToken [16]byte. InstanceToken uniquely identifies the daemon process and lets clients notice daemon restarts.

  • NewDocumentEvent — emitted when a new PKI consensus document is received from the directory authorities. The Go binding exposes the parsed document as Document *cpki.Document. (The lower-level NewPKIDocumentEvent carrying a raw CBOR Payload []byte is used internally between daemon and thin client; applications should consume NewDocumentEvent.)

  • MessageSentEvent — emitted when a SendMessage request has been transmitted by the daemon. Fields (Go): MessageID *[MessageIDLength]byte, SURBID *[SURBIDLength]byte, SentAt time.Time, ReplyETA time.Duration, Err string.

  • MessageReplyEvent — emitted when a reply to a SendMessage call is received. Fields (Go): MessageID *[MessageIDLength]byte, SURBID *[SURBIDLength]byte, Payload []byte, ReplyIndex *uint8, ErrorCode uint8.

  • DaemonDisconnectedEvent — emitted by the thin client (not the daemon) when the local socket connection to the daemon is lost. Fields (Go): IsGraceful bool, Err error.

EventSink / event_sink

Returns a channel (Go) or receiver (Rust) that yields events from the daemon. Python uses async callbacks supplied via the Config constructor, so has no equivalent method.

func (t *ThinClient) EventSink() chan Event
pub fn event_sink(&self) -> EventSinkReceiver

StopEventSink (Go only)

Stops delivering events on the given channel. Rust receivers are dropped via their own lifetime; Python’s callback model requires no teardown.

func (t *ThinClient) StopEventSink(ch chan Event)

PKI and Service Discovery

PKIDocument / pki_document

Returns the current PKI consensus document, which contains the network topology and available services.

func (t *ThinClient) PKIDocument() *cpki.Document
pub async fn pki_document(&self) -> Result<BTreeMap<Value, Value>, ThinClientError>
def pki_document(self) -> 'Dict[str,Any] | None':

GetService / get_service

Returns a random instance of the named service from the PKI document. The result contains the destination node hash and queue ID needed for sending messages to that service.

func (t *ThinClient) GetService(serviceName string) (*common.ServiceDescriptor, error)
pub async fn get_service(
    &self,
    service_name: &str,
) -> Result<ServiceDescriptor, ThinClientError>
def get_service(self, service_name: str) -> ServiceDescriptor:

GetServices / get_services

Returns all instances of a service with the given capability name. The Rust binding exposes this as a standalone helper in helpers.rs, not a method on ThinClient.

func (t *ThinClient) GetServices(capability string) ([]*common.ServiceDescriptor, error)
def get_services(self, capability: str) -> 'List[ServiceDescriptor]':

Direct Messaging

SendMessage / send_message

Sends a message with a SURB (Single Use Reply Block) that allows the destination service to reply. This method is asynchronous: it returns after the daemon accepts the request, not after the message is delivered. To receive the reply, monitor events for a MessageReplyEvent matching the SURB ID. See “Transport and Lifecycle Errors” below for the error raised when offline.

func (t *ThinClient) SendMessage(surbID *[sConstants.SURBIDLength]byte, payload []byte, destNode *[32]byte, destQueue []byte) error
pub async fn send_message(
    &self,
    surb_id: Vec<u8>,
    payload: &[u8],
    dest_node: Vec<u8>,
    dest_queue: Vec<u8>,
) -> Result<(), ThinClientError>
async def send_message(self, surb_id: bytes, payload: bytes | str, dest_node: bytes, dest_queue: bytes) -> None:

SendMessageWithoutReply / send_message_without_reply

Sends a fire-and-forget message with no SURB. The destination cannot reply.

func (t *ThinClient) SendMessageWithoutReply(payload []byte, destNode *[32]byte, destQueue []byte) error
pub async fn send_message_without_reply(
    &self,
    payload: &[u8],
    dest_node: Vec<u8>,
    dest_queue: Vec<u8>,
) -> Result<(), ThinClientError>
async def send_message_without_reply(self, payload: bytes | str, dest_node: bytes, dest_queue: bytes) -> None:

BlockingSendMessage / blocking_send_message

Sends a message and blocks until a reply is received or the timeout expires. This is a convenience wrapper that generates a SURB ID, sends the message, and waits for the matching reply event.

func (t *ThinClient) BlockingSendMessage(ctx context.Context, payload []byte, destNode *[32]byte, destQueue []byte) ([]byte, error)
pub async fn blocking_send_message(
    &self,
    payload: &[u8],
    dest_node: Vec<u8>,
    dest_queue: Vec<u8>,
    timeout: std::time::Duration,
) -> Result<Vec<u8>, ThinClientError>
async def blocking_send_message(self, payload: bytes | str, dest_node: bytes, dest_queue: bytes, timeout_seconds: float = 30.0) -> bytes:

Pigeonhole: Key Management

NewKeypair / new_keypair

Generates a new BACAP keypair from a 32-byte seed. Returns the write capability, read capability, and first message index for a new Pigeonhole stream. Does not cause network traffic.

func (t *ThinClient) NewKeypair(seed []byte) (writeCap *bacap.WriteCap, readCap *bacap.ReadCap, firstMessageIndex *bacap.MessageBoxIndex, err error)
pub async fn new_keypair(
    &self,
    seed: &[u8; 32],
) -> Result<KeypairResult, ThinClientError>
async def new_keypair(self, seed: bytes) -> KeypairResult:

Pigeonhole: Index Management

NextMessageBoxIndex / next_message_box_index

Returns the next message box index in the sequence. This is a local KDF computation — it does not cause network traffic or store state. If you have a BACAP implementation in your language, you can compute this locally instead of making a round trip to the daemon.

func (t *ThinClient) NextMessageBoxIndex(messageBoxIndex *bacap.MessageBoxIndex) (nextMessageBoxIndex *bacap.MessageBoxIndex, err error)
pub async fn next_message_box_index(
    &self,
    message_box_index: &[u8],
) -> Result<Vec<u8>, ThinClientError>
async def next_message_box_index(self, message_box_index: bytes) -> bytes:

Pigeonhole: Encryption

EncryptRead / encrypt_read

Creates an encrypted read request for a single Pigeonhole box. The returned ciphertext and envelope data must be sent via StartResendingEncryptedMessage to actually perform the read. Does not cause network traffic.

func (t *ThinClient) EncryptRead(readCap *bacap.ReadCap, messageBoxIndex *bacap.MessageBoxIndex) (messageCiphertext []byte, envelopeDescriptor []byte, envelopeHash *[32]byte, nextMessageBoxIndex *bacap.MessageBoxIndex, err error)
pub async fn encrypt_read(
    &self,
    read_cap: &[u8],
    message_box_index: &[u8],
) -> Result<EncryptReadResult, ThinClientError>
async def encrypt_read(self, read_cap: bytes, message_box_index: bytes) -> EncryptReadResult:

EncryptWrite / encrypt_write

Creates an encrypted write request for a single Pigeonhole box. The returned ciphertext and envelope data must be sent via StartResendingEncryptedMessage to actually perform the write. To create a tombstone (deletion marker), pass an empty byte slice as the plaintext.

func (t *ThinClient) EncryptWrite(plaintext []byte, writeCap *bacap.WriteCap, messageBoxIndex *bacap.MessageBoxIndex) (messageCiphertext []byte, envelopeDescriptor []byte, envelopeHash *[32]byte, nextMessageBoxIndex *bacap.MessageBoxIndex, err error)
pub async fn encrypt_write(
    &self,
    plaintext: &[u8],
    write_cap: &[u8],
    message_box_index: &[u8],
) -> Result<EncryptWriteResult, ThinClientError>
async def encrypt_write(self, plaintext: bytes, write_cap: bytes, message_box_index: bytes) -> EncryptWriteResult:

Pigeonhole: ARQ Transport

StartResendingEncryptedMessage / start_resending_encrypted_message

Sends an encrypted read or write request to a courier via the ARQ (Automatic Repeat reQuest) mechanism. Blocks until the operation completes or is cancelled. The daemon retransmits the request until it receives an acknowledgment from the courier. By default writes treat BoxAlreadyExists as idempotent success, and reads retry indefinitely on BoxIDNotFound.

func (t *ThinClient) StartResendingEncryptedMessage(readCap *bacap.ReadCap, writeCap *bacap.WriteCap, messageBoxIndex []byte, replyIndex *uint8, envelopeDescriptor []byte, messageCiphertext []byte, envelopeHash *[32]byte) (*StartResendingResult, error)
pub async fn start_resending_encrypted_message(
    &self,
    read_cap: Option<&[u8]>,
    write_cap: Option<&[u8]>,
    message_box_index: Option<&[u8]>,
    reply_index: Option<u8>,
    envelope_descriptor: &[u8],
    message_ciphertext: &[u8],
    envelope_hash: &[u8; 32],
) -> Result<StartResendingResult, ThinClientError>
async def start_resending_encrypted_message(self, read_cap: 'bytes|None', write_cap: 'bytes|None', message_box_index: 'bytes|None', reply_index: 'int|None', envelope_descriptor: bytes, message_ciphertext: bytes, envelope_hash: bytes, no_retry_on_box_id_not_found: bool = False, no_idempotent_box_already_exists: bool = False) -> StartResendingResult:

StartResendingEncryptedMessageReturnBoxExists

Same as the default variant, but returns BoxAlreadyExists as an error instead of treating it as success. Use this when you need to distinguish a new write from a repeated one.

func (t *ThinClient) StartResendingEncryptedMessageReturnBoxExists(readCap *bacap.ReadCap, writeCap *bacap.WriteCap, messageBoxIndex []byte, replyIndex *uint8, envelopeDescriptor []byte, messageCiphertext []byte, envelopeHash *[32]byte) (*StartResendingResult, error)
pub async fn start_resending_encrypted_message_return_box_exists(
    &self,
    read_cap: Option<&[u8]>,
    write_cap: Option<&[u8]>,
    message_box_index: Option<&[u8]>,
    reply_index: Option<u8>,
    envelope_descriptor: &[u8],
    message_ciphertext: &[u8],
    envelope_hash: &[u8; 32],
) -> Result<StartResendingResult, ThinClientError>
async def start_resending_encrypted_message_return_box_exists(self, read_cap: 'bytes|None', write_cap: 'bytes|None', message_box_index: 'bytes|None', reply_index: 'int|None', envelope_descriptor: bytes, message_ciphertext: bytes, envelope_hash: bytes) -> StartResendingResult:

StartResendingEncryptedMessageNoRetry

Same as the default variant, but returns BoxIDNotFound immediately instead of retrying indefinitely. Use this when polling for a message that may not exist yet.

func (t *ThinClient) StartResendingEncryptedMessageNoRetry(readCap *bacap.ReadCap, writeCap *bacap.WriteCap, messageBoxIndex []byte, replyIndex *uint8, envelopeDescriptor []byte, messageCiphertext []byte, envelopeHash *[32]byte) (*StartResendingResult, error)
pub async fn start_resending_encrypted_message_no_retry(
    &self,
    read_cap: Option<&[u8]>,
    write_cap: Option<&[u8]>,
    message_box_index: Option<&[u8]>,
    reply_index: Option<u8>,
    envelope_descriptor: &[u8],
    message_ciphertext: &[u8],
    envelope_hash: &[u8; 32],
) -> Result<StartResendingResult, ThinClientError>
async def start_resending_encrypted_message_no_retry(self, read_cap: 'bytes|None', write_cap: 'bytes|None', message_box_index: 'bytes|None', reply_index: 'int|None', envelope_descriptor: bytes, message_ciphertext: bytes, envelope_hash: bytes) -> StartResendingResult:

CancelResendingEncryptedMessage / cancel_resending_encrypted_message

Cancels an in-flight StartResendingEncryptedMessage operation. The blocked caller receives a StartResendingCancelled error. Removes the operation from in-flight tracking so it will not be replayed on reconnect.

func (t *ThinClient) CancelResendingEncryptedMessage(envelopeHash *[32]byte) error
pub async fn cancel_resending_encrypted_message(
    &self,
    envelope_hash: &[u8; 32],
) -> Result<(), ThinClientError>
async def cancel_resending_encrypted_message(self, envelope_hash: bytes) -> None:

Pigeonhole: Tombstones

TombstoneRange / tombstone_range

Creates encrypted tombstone envelopes for a range of consecutive boxes. Tombstones are writes with an empty payload that delete the box contents. Does not cause network traffic; the caller must send each envelope individually via StartResendingEncryptedMessage.

func (c *ThinClient) TombstoneRange(
	writeCap *bacap.WriteCap,
	start *bacap.MessageBoxIndex,
	maxCount uint32,
) (result *TombstoneRangeResult, err error)
pub async fn tombstone_range(
    &self,
    write_cap: &[u8],
    start: &[u8],
    max_count: u32,
) -> TombstoneRangeResult
async def tombstone_range(self, write_cap: bytes, start: bytes, max_count: int) -> TombstoneRangeResult:

Pigeonhole: Copy Stream Construction

CreateCourierEnvelopesFromPayload

Packs a single payload for one destination into copy stream elements. The payload can be up to ~10 MB. Returns properly sized chunks ready to be written to boxes via EncryptWrite.

func (t *ThinClient) CreateCourierEnvelopesFromPayload(payload []byte, destWriteCap *bacap.WriteCap, destStartIndex *bacap.MessageBoxIndex, isStart bool, isLast bool) (envelopes [][]byte, nextDestIndex *bacap.MessageBoxIndex, err error)
pub async fn create_courier_envelopes_from_payload(
    &self,
    payload: &[u8],
    dest_write_cap: &[u8],
    dest_start_index: &[u8],
    is_start: bool,
    is_last: bool,
) -> Result<CreateEnvelopesResult, ThinClientError>
async def create_courier_envelopes_from_payload(self, payload: bytes, dest_write_cap: bytes, dest_start_index: bytes, is_start: bool, is_last: bool) -> 'CreateEnvelopesResult':

CreateCourierEnvelopesFromMultiPayload

Packs multiple payloads for different destinations into copy stream elements. More space-efficient than calling the single-payload variant multiple times because envelopes are packed together without wasted padding.

func (t *ThinClient) CreateCourierEnvelopesFromMultiPayload(destinations []DestinationPayload, isStart bool, isLast bool, buffer []byte) (*CreateEnvelopesResult, error)
pub async fn create_courier_envelopes_from_multi_payload(
    &self,
    destinations: Vec<(&[u8], &[u8], &[u8])>,
    is_start: bool,
    is_last: bool,
    buffer: Option<Vec<u8>>,
) -> Result<CreateEnvelopesResult, ThinClientError>
async def create_courier_envelopes_from_multi_payload(self, destinations: 'List[Dict[str, Any]]', is_start: bool, is_last: bool, buffer: 'bytes | None' = None) -> 'CreateEnvelopesResult':

CreateCourierEnvelopesFromTombstoneRange

Packs tombstone envelopes for a range of consecutive destination boxes into copy stream elements. Use this to atomically tombstone boxes as part of a copy command.

func (t *ThinClient) CreateCourierEnvelopesFromTombstoneRange(
	destWriteCap *bacap.WriteCap,
	destStartIndex *bacap.MessageBoxIndex,
	maxCount uint32,
	isStart bool,
	isLast bool,
	buffer []byte,
) (envelopes [][]byte, nextBuffer []byte, nextDestIndex *bacap.MessageBoxIndex, err error)
pub async fn create_courier_envelopes_from_tombstone_range(
    &self,
    dest_write_cap: &[u8],
    dest_start_index: &[u8],
    max_count: u32,
    is_start: bool,
    is_last: bool,
    buffer: Option<Vec<u8>>,
) -> Result<CreateEnvelopesResult, ThinClientError>
async def create_courier_envelopes_from_tombstone_range(self, dest_write_cap: bytes, dest_start_index: bytes, max_count: int, is_start: bool, is_last: bool, buffer: 'bytes | None' = None) -> 'CreateEnvelopesResult':

Pigeonhole: Copy Command Transport

StartResendingCopyCommand / start_resending_copy_command

Sends a copy command to a courier via ARQ and blocks until the courier acknowledges completion. The courier reads the temporary copy stream, executes each envelope, tombstones the temporary stream, and sends an ACK. The Rust and Python bindings take optional courier-identity parameters to pin the command to a specific courier; the Go binding exposes this as a separate method (see below).

func (t *ThinClient) StartResendingCopyCommand(writeCap *bacap.WriteCap) error
pub async fn start_resending_copy_command(
    &self,
    write_cap: &[u8],
    courier_identity_hash: Option<&[u8]>,
    courier_queue_id: Option<&[u8]>,
) -> Result<(), ThinClientError>
async def start_resending_copy_command(self, write_cap: bytes, courier_identity_hash: 'bytes|None' = None, courier_queue_id: 'bytes|None' = None) -> None:

StartResendingCopyCommandWithCourier (Go only)

Like StartResendingCopyCommand but sends to a specific courier instead of a random one. Used for nested copy commands. In Rust and Python this is achieved by supplying the optional courier_identity_hash and courier_queue_id arguments to start_resending_copy_command.

func (t *ThinClient) StartResendingCopyCommandWithCourier(
	writeCap *bacap.WriteCap,
	courierIdentityHash *[32]byte,
	courierQueueID []byte,
) error

CancelResendingCopyCommand / cancel_resending_copy_command

Cancels an in-flight copy command. The parameter is the blake2b-256 hash of the serialized write capability.

func (t *ThinClient) CancelResendingCopyCommand(writeCapHash *[32]byte) error
pub async fn cancel_resending_copy_command(
    &self,
    write_cap_hash: &[u8; 32],
) -> Result<(), ThinClientError>
async def cancel_resending_copy_command(self, write_cap_hash: bytes) -> None:

Pigeonhole: Courier Discovery

GetAllCouriers / get_all_couriers

Returns every courier service advertised in the current PKI document. Not yet exposed in the Rust binding.

func (t *ThinClient) GetAllCouriers() (couriers []CourierDescriptor, err error)
def get_all_couriers(self) -> 'List[Tuple[bytes, bytes]]':

GetDistinctCouriers / get_distinct_couriers

Returns n distinct couriers drawn at random from the current PKI document. Not yet exposed in the Rust binding.

func (t *ThinClient) GetDistinctCouriers(n int) (couriers []CourierDescriptor, err error)
def get_distinct_couriers(self, n: int) -> 'List[Tuple[bytes, bytes]]':

get_courier_destination (Rust only)

Returns a single courier destination as a (identity_hash, queue_id) tuple, sparing the caller from handling a list. Go and Python callers achieve the same by calling GetDistinctCouriers(1) / get_distinct_couriers(1) and taking the first element.

pub async fn get_courier_destination(
    &self,
) -> Result<(Vec<u8>, Vec<u8>), ThinClientError>

pigeonhole_geometry (Rust only)

Returns a reference to the negotiated Pigeonhole geometry, so that callers can size payloads to its max_plaintext_payload_length. Go callers obtain this via GetConfig().PigeonholeGeometry; the Python binding stores it internally but does not currently expose an accessor.

pub fn pigeonhole_geometry(&self) -> &PigeonholeGeometry

Utility

NewMessageID / new_message_id

Returns a new random message ID (16 bytes).

func (t *ThinClient) NewMessageID() *[MessageIDLength]byte
pub fn new_message_id() -> Vec<u8>
def new_message_id() -> bytes:

NewSURBID / new_surb_id

Returns a new random SURB ID for correlating message replies.

func (t *ThinClient) NewSURBID() *[sConstants.SURBIDLength]byte
pub fn new_surb_id() -> Vec<u8>
def new_surb_id(self) -> bytes:

NewQueryID / new_query_id

Returns a new random query ID for correlating requests and replies within the thin client protocol.

func (t *ThinClient) NewQueryID() *[QueryIDLength]byte
pub fn new_query_id() -> Vec<u8>
def new_query_id(self) -> bytes:

Transport and Lifecycle Errors

These errors can in principle be raised by any method that performs I/O against the daemon or the mixnet.

Condition Go Rust Python
Daemon not connected to mixnet ad-hoc error with message “cannot send message in offline mode - daemon not connected to mixnet” (no sentinel — check IsConnected() first) ThinClientError::OfflineMode(String) ThinClientOfflineError
Operation timed out context.DeadlineExceeded (from ctx.Err()) ThinClientError::Timeout(String) asyncio.TimeoutError
Operation cancelled by caller context.Canceled (from ctx.Err()) (no distinct variant — uses higher-level cancellation) asyncio.CancelledError
Local socket to kpclientd lost returned on the next I/O; thin client attempts reconnect with exponential backoff ditto (receive DaemonDisconnectedEvent on the event sink) ditto
CBOR (de)serialisation failure wrapped error ThinClientError::CborError(serde_cbor::Error) serde-layer exception bubbles up

The Go binding does not provide a named sentinel for offline mode. Applications that must distinguish “daemon offline” from other errors should test IsConnected() before sending, not compare error values after the fact. The Rust and Python bindings provide proper sentinels testable with matches! / isinstance.

Replica and Courier Errors

The errors below can be returned by StartResendingEncryptedMessage and its variants. They are defined in pigeonhole/errors.go.

Errors specific to reads (when readCap is set)

Error Go Rust Python
Box not found (retries exhausted) ErrBoxIDNotFound ThinClientError::BoxNotFound BoxIDNotFoundError
MKEM decryption failed ErrMKEMDecryptionFailed ThinClientError::MkemDecryptionFailed MKEMDecryptionFailedError
BACAP decryption failed ErrBACAPDecryptionFailed ThinClientError::BacapDecryptionFailed BACAPDecryptionFailedError
Tombstone (box was deleted) ErrTombstone ThinClientError::Tombstone TombstoneError

Errors specific to writes (when writeCap is set)

Error Go Rust Python
Storage full ErrStorageFull ThinClientError::StorageFull StorageFullError

Errors on both reads and writes

Error Go Rust Python
Operation cancelled ErrStartResendingCancelled ThinClientError::StartResendingCancelled StartResendingCancelledError
Invalid box ID ErrInvalidBoxID ThinClientError::InvalidBoxId InvalidBoxIDError
Invalid signature ErrInvalidSignature ThinClientError::InvalidSignature InvalidSignatureError
Invalid tombstone signature ErrInvalidTombstoneSignature ThinClientError::InvalidTombstoneSignature InvalidTombstoneSignatureError
Database failure ErrDatabaseFailure ThinClientError::DatabaseFailure DatabaseFailureError
Invalid payload ErrInvalidPayload ThinClientError::InvalidPayload InvalidPayloadError
Invalid epoch ErrInvalidEpoch ThinClientError::InvalidEpoch InvalidEpochError
Replication failed ErrReplicationFailed ThinClientError::ReplicationFailed ReplicationFailedError
Replica internal error ErrReplicaInternalError ThinClientError::ReplicaInternalError ReplicaInternalError
Box already exists (writes only, when non-idempotent variant used) ErrBoxAlreadyExists ThinClientError::BoxAlreadyExists BoxAlreadyExistsError

Copy-command failure

StartResendingCopyCommand can return a diagnostic error carrying the underlying replica error code and the 1-based sequential envelope index at which processing stopped:

Binding Error
Go ErrCopyCommandFailed (see CopyCommandFailedError struct for fields)
Rust ThinClientError::CopyCommandFailed { replica_error_code, failed_envelope_index }
Python CopyCommandFailedError(replica_error_code, failed_envelope_index)

Expected Outcomes vs Real Failures

Some errors from StartResendingEncryptedMessage represent completed operations, not failures. Use IsExpectedOutcome(err) (Go), err.is_expected_outcome() (Rust), or is_expected_outcome(exc) (Python) to distinguish them:

Error Why it may be expected
BoxIDNotFound / BoxNotFound Polling for a message that hasn’t been written yet
BoxAlreadyExists Retrying an idempotent write that already succeeded
Tombstone Reading a box that was intentionally deleted

These should generally not trigger retries in your application.