Skip to content

refactor!: preserve typed error sources across the workspace - #597

Merged
jlucaso1 merged 8 commits into
mainfrom
refactor/preserve-typed-error-sources
Apr 26, 2026
Merged

refactor!: preserve typed error sources across the workspace#597
jlucaso1 merged 8 commits into
mainfrom
refactor/preserve-typed-error-sources

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Summary

6-commit series eliminating the error.to_string() / format!("{e}") anti-pattern across the workspace. Every typed error from one layer was being flattened into a String at the next, dropping .source() chains and forcing downstream consumers to parse English prose to discriminate cases (e.g. code=400 vs timeout vs disconnect).

Principle applied: um erro só vira String no momento de imprimir, nunca antes. Breaking changes accepted because the code is pre-1.0 and the wrong thing was on the public surface.

Series (each commit is independent and reviewable on its own)

  1. refactor(pair-code)! — Split PairCodeError. wacore variants kept (validation, key derivation, AEAD), high-level PairError introduced wrapping PairCodeError + IqError. CryptoError(String) exploded into typed variants for CurveError (key parse, key agreement) and CryptoProviderError (AES-GCM).
  2. refactor(socket,noise,iq)!NoiseError::CryptoError(String)Encrypt/Decrypt(#[source] CryptoProviderError). SocketError::Crypto/NoiseHandshake removed; new Cipher(#[from] NoiseError) and Marshal(#[source] BinaryError). IqError got EncryptSend(#[from]) + ClientState(#[source]) for the catch-all that previously flattened ClientError. Two dead variants (SocketError::NoiseHandshake, wacore::request::IqError::Network) deleted.
  3. refactor(media-download)!MediaDecryptionError::Decryption(String)Decryption(#[source] DecryptionError) + new Mac(#[source] CryptoError).
  4. refactor(mex)!MexError::PayloadParsing(String) kept for the 15+ descriptive call sites; one site that wrapped a real JidError got moved to a new InvalidJid(#[from] JidError) variant.
  5. refactor(libsignal,store)! — Added SignalProtocolError::BackendError(&'static str, #[source] Box<dyn Error+Send+Sync>) to vendored libsignal (ApplicationCallbackError requires UnwindSafe, which StoreError::Backend doesn't satisfy). 4 call sites in signal_adapter/signal.rs switched. Documented divergence from upstream Signal.
  6. refactor(store)! — Largest commit. StoreError's 4 String variants (Database, Connection, Migration, Serialization) all gained #[source] Box<dyn Error+Send+Sync>. New variants: Validation(String) for descriptive validation, RetriesExhausted { op }, InvalidConfig(String). Dead variants removed (Backend, NotFound, DeviceNotFound). 30+ call sites in sqlite-storage updated. New is_database_busy_or_locked() helper walks the source chain — replaces the substring matches!(... msg.contains(\"locked\")) anti-pattern in client.rs:2510. Retired db_err() helper (was wrap-already-wrapped — backend already returned StoreError).

Breaking changes

Surface-level (anyone matching variants):

  • PairCodeError::CryptoError, PairCodeError::RequestFailed removed; replaced by typed variants and the new PairError wrapper.
  • Client::pair_with_code returns PairError (not PairCodeError).
  • SocketError::Crypto, SocketError::NoiseHandshake removed.
  • NoiseError::CryptoError removed; NoiseError no longer Clone.
  • wacore::request::IqError::Network removed.
  • IqError gained EncryptSend + ClientState; pattern matching needs to cover them.
  • MediaDecryptionError::Decryption(String) now carries DecryptionError.
  • StoreError::{Database, Connection, Migration, Serialization} carry typed sources, not Strings; Backend, NotFound, DeviceNotFound removed.
  • db_err helper removed.
  • SignalProtocolError got a new BackendError variant (vendored libsignal divergence).

Display messages on most touched variants are lowercased and dropped the : {0} suffix when source is preserved (avoids duplication when consumers walk the chain via tracing::error!(error = ?e) or anyhow).

Source-chain tests

Each touched enum has a test verifying error.source() returns the expected concrete type via downcast_ref:

  • wacore::pair_code::tests::invalid_primary_ephemeral_key_preserves_curve_source
  • wacore::pair_code::tests::bundle_aead_preserves_crypto_provider_source
  • whatsapp_rust::pair_code::tests::pair_error_request_failed_preserves_iq_source
  • whatsapp_rust::pair_code::tests::pair_error_paircode_transparent_walks_to_curve_error
  • whatsapp_rust::socket::error::tests::cipher_preserves_noise_source_through_socket_error (two-hop downcast)
  • whatsapp_rust::socket::error::tests::marshal_preserves_binary_error_source
  • wacore::download::tests::media_decryption_decryption_preserves_aes_cbc_source
  • wacore::download::tests::media_decryption_mac_preserves_crypto_error_source
  • whatsapp_rust::features::mex::tests::invalid_jid_preserves_jid_error_source
  • whatsapp_rust::features::mex::tests::request_preserves_iq_error_source
  • wacore_libsignal::protocol::error::tests::backend_error_preserves_typed_source_via_downcast
  • wacore::store::error::tests::database_preserves_typed_source_via_downcast
  • wacore::store::error::tests::is_busy_or_locked_walks_chain (+ negative case)

Acceptance criteria

  • grep -rn 'map_err.*\.to_string()' src wacore/src returns only the two annotated // reason: lines in wacore/src/store/device.rs (serde boundary, no chain support).
  • cargo test --workspace --exclude e2e-tests --exclude bench-integration — all passing.
  • cargo clippy --workspace --exclude e2e-tests --exclude bench-integration --all-targets -- -D warnings — clean.

Test plan

  • cargo test --workspace --exclude e2e-tests
  • cargo clippy --workspace -- -D warnings
  • cargo test -p e2e-tests (requires mock server — not run locally)
  • Smoke-test Veloz against this branch to confirm the high-level error surface still works at the consumer side

…igh-level PairError

Eliminates two error-flattening anti-patterns in the pair-code flow.

Antes: `PairCodeError::CryptoError(String)` recebia `format!("{e}")` de três
families distintas — `CurveError` (key parsing/DH), `CryptoProviderError`
(AES-GCM), `hkdf::InvalidLength`. Consumers downstream perdiam o tipo concreto e
não conseguiam discriminar via `error.source()` + downcast.
Depois: variantes específicas — `InvalidPrimaryEphemeralKey`,
`InvalidPrimaryIdentityKey`, `EphemeralKeyAgreement`, `IdentityKeyAgreement`
(todas `#[source] CurveError`), `BundleAead(#[source] CryptoProviderError)`,
`AdvSecretKeyDerivation` e `BundleKeyDerivation` (HKDF é unit-like, sem source
útil).

Antes: `PairCodeError::RequestFailed(String)` recebia `e.to_string()` de
`whatsapp_rust::request::IqError` no único call site (`pair_with_code`),
descartando code/text de erros do servidor (e.g. ServerError { code: 400 }).
PairCodeError vive em wacore, que não pode depender do high-level IqError.
Depois: removida da wacore. Novo enum `whatsapp_rust::pair_code::PairError` no
high-level wraps `PairCodeError` (transparent) + `IqError` via `#[from]`.
`pair_with_code` agora retorna `PairError`. Consumidor pode caminhar
`error.source()` e fazer `downcast_ref::<IqError>()`.

Breaking changes:
- `pub fn Client::pair_with_code(...)` retorna `PairError` em vez de
  `PairCodeError`. Mensagens de Display também mudaram pra lowercase
  (consistente com Rust idioms).
- `wacore::pair_code::PairCodeError::CryptoError` e `RequestFailed` removidas.
- Mensagens `Display` de `PairCodeError` agora são lowercase.

Tests adicionados pra cada variante com source: downcast verifica que o tipo
concreto sobrevive ao wrap.
…, IqError

Cadeia de three-step refactor pra preservar tipo do call path
NoiseCipher → SocketError → IqError, e remover dead code.

NoiseError (wacore-noise):
- `CryptoError(String)` substituído por `Encrypt(#[source] CryptoProviderError)`
  e `Decrypt(#[source] CryptoProviderError)` — variantes distintas porque o
  call site sabe qual operação fez. Strings tipo `"Decrypt failed: ..."` e
  `"Ciphertext too short (missing tag)"` viraram variante `CiphertextTooShort`
  unit ou source typado.
- Removido o `Clone` impl porque `CryptoProviderError` não é `Clone` (nem
  precisava ser; nenhum consumer clonava NoiseError).

SocketError (high-level):
- `Crypto(String)` REMOVIDO. Substituído por `Cipher(#[from] NoiseError)` no
  caminho noise (decrypt_frame). Strings vagas como `"Marshal error"` em
  client.rs viraram `Marshal(#[source] BinaryError)`.
- `NoiseHandshake(String)` REMOVIDO — dead code, nenhum produtor.

IqError (high-level + wacore):
- wacore: `Network(String)` REMOVIDO — dead variant, nenhum constructor.
- High-level: catch-all `_ => SocketError::Crypto(e.to_string())` em
  send_and_wait_iq virou matching exaustivo: `EncryptSend(#[from]
  EncryptSendError)` pra encrypt-pipeline e `ClientState(#[source]
  ClientError)` pra AlreadyConnected/NotLoggedIn.
- Mensagens `Display` ajustadas pra lowercase + remoção do `: {0}` quando
  source carrega o detalhe (evita duplicação na chain).
- `EncodeError(anyhow::Error)` ganhou `#[source]` (estava simplesmente nu).

Tests adicionados em src/socket/error.rs verificam que SocketError → NoiseError
→ CryptoProviderError sobrevive a dois hops de downcast via error.source().
Atualiza keepalive test que usava o SocketError::Crypto removido.

Breaking changes:
- SocketError::{Crypto, NoiseHandshake} removidos.
- NoiseError::CryptoError removida e Clone removido.
- wacore::request::IqError::Network removida.
- IqError::EncodeError ganhou #[source] (afeta apenas Display em chain
  walking, semântica do construtor é igual).
`MediaDecryptionError::Decryption(String)` was wrapping two distinct typed
errors via `e.to_string()`: `CryptographicMac::new` returns
`crypto::Error` (algorithm catalogue) e `aes_256_cbc_decrypt_into` returns
`DecryptionError` (BadKeyOrIv / BadCiphertext). Achatamento descartava info
acionável — ex. distinção entre erro de chave inválida vs ciphertext corrompido.

Antes:
  Decryption(String)

Depois:
  Decryption(#[source] DecryptionError)  // AES-CBC
  Mac(#[source] CryptoError)             // HMAC catalog lookup

`decrypt_cbc` (que retorna anyhow::Result) também tinha
`.map_err(|e| anyhow!(e.to_string()))` — agora `anyhow::Error::new(e)`
preserva o source via blanket impl.

Tests verificam preservação de cada source.

Breaking: `MediaDecryptionError::Decryption(String)` agora carrega
`DecryptionError`. Consumers que faziam pattern matching extraindo a
String quebram (exigência: leiam o source via `.source()` + downcast).
…iant

Most call sites of `PayloadParsing(String)` carry descriptive strings without
a source error (e.g. \"missing data\", \"missing newsletter id\") — those stay.
But one call site in newsletter.rs was wrapping a real `JidError` via
`format!(\"invalid newsletter JID: {e}\")` — that flattens the structured JID
parse failure into prose.

Antes:
  PayloadParsing(String)  // catch-all for both descriptive AND typed cases

Depois:
  PayloadParsing(String)         // descriptive (no source) — unchanged
  InvalidJid(#[from] JidError)   // wraps real source; #[from] enables `?`

Mensagens `Display` de variantes com `#[source]`/`#[from]` removeram o
prefixo `: {0}` pra evitar duplicação na chain (tracing/anyhow caminham
source automaticamente).

Tests verificam preservação do JidError e IqError sources.

Breaking: variantes Request e Json antes mostravam o source na mensagem;
agora delegam para a chain. Consumidores que usavam `e.to_string()` direto
para obter o source vão precisar caminhar `e.source()`.
`SignalProtocolError::InvalidState(\"backend\", e.to_string())` was the standard
way for the high-level Signal store adapter to wrap a backend (Diesel/SQLite)
error — and it threw away the source type. Existing `ApplicationCallbackError`
requires `UnwindSafe`, which `StoreError::Backend` (a `Box<dyn Error+Send+Sync>`)
doesn't satisfy.

Diverging from upstream libsignal: adicionada nova variante:

  /// backend store error in {0}
  BackendError(&'static str, #[source] Box<dyn Error + Send + Sync>)

Sem `UnwindSafe` — match sem o requirement aqui é proposital, esses erros não
viajam por panic boundaries. Diverge de upstream Signal pra preservar a chain
no caso ubíquo de adapter.

`signal_err()` helper em `signal_adapter.rs` agora aceita qualquer
`Into<Box<dyn Error+Send+Sync>>` — compatível com `StoreError`, `anyhow::Error`
(usado pelo cache layer), e qualquer typed error futuro. Os 4 call sites diretos
em `src/store/signal.rs` (put_identity, get_identity, store_sender_key,
load_sender_key) agora usam `BackendError`.

Test em `wacore_libsignal::protocol::error::tests` verifica downcast.

Breaking: SignalProtocolError ganhou nova variante (não-exhaustiva por
default em libsignal). Pattern matching exaustivo em consumers agora exige
arm pra BackendError.
Maior refactor da série. StoreError tinha 4 variantes que carregavam String
de fontes tipadas ricas (Diesel, r2d2, tokio Semaphore/Join, std::io,
diesel-migrations). Achatamento via .to_string() em todos os 30+ call sites
de sqlite-storage. Plus o anti-padrão `db_err` helper (usado em 14 lugares)
fazia DOUBLE wrap: backend já retornava StoreError, e db_err re-wrappava em
StoreError::Database(StoreError.to_string()).

StoreError mudanças:
- Database/Connection/Migration/Serialization: `(String)` →
  `(#[source] Box<dyn Error + Send + Sync>)`. Source preservado, downcast
  funciona pra Diesel/r2d2/etc.
- NEW Validation(String) — pra mensagens descritivas SEM source típico (ex.
  \"Invalid foo length: 17\"). Substitui usos errôneos de Serialization(String).
- NEW RetriesExhausted { op: String } — substitui o pattern
  `format!(\"X exhausted retries\")` repetido em ~6 lugares.
- NEW InvalidConfig(String) — pra config errors (\"in-memory not supported\",
  \"snapshot name too long\").
- REMOVED Backend(#[from]), NotFound(String), DeviceNotFound(i32) — dead
  variantes (zero call sites no workspace).
- ADDED helper `is_database_busy_or_locked(&self) -> bool` que walk source
  chain inspecionando Display em cada layer. Substitui o pattern de
  `matches!(StoreError::Database(msg) if msg.contains(\"locked\"))` em
  client.rs:2510 — a substring matching agora é centralizada e funciona
  através de qualquer layer da chain.

Removed `db_err` helper:
- Todos os 14 call sites em src/store/persistence_manager.rs e
  wacore/src/store/persistence.rs eram wrap-already-wrapped (backend já
  retornava StoreError). Substituído por `?` direto que preserva o
  StoreError typed.

sqlite-storage: 30+ map_err sites convertidos. `e.to_string()` →
`Box::new(e)` pra Diesel/r2d2/Semaphore/Join/io errors. Os format!()
\"exhausted retries\" / \"Identity write failed after N attempts\" convertidos
pra `RetriesExhausted { op }`. As validações do tipo \"Invalid X length\"
movidas pra `Validation`.

src/client.rs:2510 substring check de \"locked\"/\"busy\" agora chama
`is_database_busy_or_locked()` que walk a source chain. AppStateSyncError
match também simplificado.

wacore::store::device::deserialize tem dois `serde::de::Error::custom(e.to_string())`
que ficam — anotados com `// reason:` (serde error model não preserva chain).

Tests no wacore/src/store/error.rs verificam: downcast preservado,
is_database_busy_or_locked walks a chain (positive + negative).

Breaking changes:
- StoreError shape mudou completamente. Pattern matching em variants
  Database/Connection/Migration/Serialization agora carrega
  `Box<dyn Error+Send+Sync>` em vez de String.
- Variantes Backend, NotFound, DeviceNotFound removidas.
- `db_err` helper removido.
@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown
📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • More reliable detection of database “busy/locked” conditions and improved keepalive/error classification.
    • Preserve underlying error sources for crypto, marshal, and media decryption failures so root causes are retained.
  • New Features

    • Pair-code API now exposes a dedicated error type with richer diagnostics.
    • Validation now surfaces typed invalid-JID errors.
  • Refactor

    • Broad overhaul of error handling: structured error sources replace stringified errors across storage, noise/crypto, handshake and persistence layers.

Walkthrough

This PR replaces many string-based error payloads with typed error variants that preserve error sources and adds contextual error helpers; it updates error propagation and classification across socket, IQ/request, store, noise, signal, pair-code, and download components.

Changes

Cohort / File(s) Summary
Socket layer
src/socket/error.rs, src/socket/noise_socket.rs
SocketError now has typed Cipher(NoiseError) and Marshal(BinaryError) variants; decrypt and marshal failures return typed variants preserving sources.
IQ / request layer
src/request.rs, wacore/src/request.rs
IqError adds EncryptSend and ClientState variants, removes Network(String), and preserves anyhow::Error as EncodeError source; mappings from ClientError are more specific.
Store surface & persistence
wacore/src/store/error.rs, src/store/error.rs, src/store/persistence_manager.rs, wacore/src/store/persistence.rs, storages/sqlite-storage/src/sqlite_store.rs
StoreError variants now wrap typed boxed sources; new Validation, RetriesExhausted { op }, InvalidConfig; db_err removed; persistence and sqlite store propagate backend errors directly.
Signal protocol backend
wacore/libsignal/src/protocol/error.rs, src/store/signal.rs, src/store/signal_adapter.rs
Adds SignalProtocolError::BackendError(&'static str, Box<dyn Error>); replaces stringified backend mappings with boxed-source BackendError.
Noise cipher
wacore/noise/src/error.rs, wacore/noise/src/state.rs, wacore/src/handshake/mod.rs
NoiseError loses Clone, replaces generic crypto string with Encrypt/Decrypt (wrapping CryptoProviderError) and CiphertextTooShort; re-exported via handshake mod.
Media & Pair-code
wacore/src/download.rs, wacore/src/pair_code.rs, src/pair_code.rs
MediaDecryptionError gains typed Decryption(AesCbcDecryptionError) and Mac(CryptoError); PairCodeError refactored to typed crypto variants; new PairError wraps PairCodeError and IqError.
Features & newsletter
src/features/mex.rs, src/features/newsletter.rs
MexError adds InvalidJid(#[from] JidError) and simplifies display strings; newsletter JID parsing now uses direct jid_str.parse()? propagation.
Keepalive & client retry behavior
src/keepalive.rs, src/client.rs
Keepalive treats IqError::EncryptSend and IqError::ClientState as fatal; client retry logic now uses StoreError::is_database_busy_or_locked(); send_node returns SocketError::Marshal(e) for marshal failures.
Misc tests & docs
various tests across modules, wacore/src/store/device.rs comment
Added/updated unit tests to assert error source preservation and busy/locked detection; one doc comment notes serde error flattening.

Sequence Diagram(s)

sequenceDiagram
actor Client
participant Persistence as PersistenceManager/Store
participant Socket
participant IQ as IQ Transport
participant Keepalive

Client->>Persistence: perform backend op (save/load)
alt backend returns busy/locked
    Persistence-->>Client: StoreError (source boxed)
    Client->>Client: StoreError::is_database_busy_or_locked()
    Client-->>Client: decide retry or abort
else backend ok
    Persistence-->>Client: success
end
Client->>Socket: marshal & send node
alt marshal fails
    Socket-->>Client: SocketError::Marshal (BinaryError source)
else encrypt/decrypt fail
    Socket-->>Client: SocketError::Cipher (NoiseError source)
    Socket->>IQ: send IQ
    IQ-->>Keepalive: IqError::EncryptSend / ClientState
    Keepalive->>Keepalive: classify as FatalFailure
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

You broke a lot of stringly-typed error plumbing and replaced it with proper source-preserving variants — good, but verify every downstream mapping and test the integration paths end-to-end. Things need to fail with inspectable causes, not opaque strings.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'refactor!: preserve typed error sources across the workspace' directly and accurately describes the main refactoring objective to eliminate string-based error conversions and preserve error source chains.
Description check ✅ Passed The description comprehensively explains the rationale, scope, breaking changes, and acceptance criteria for preserving typed error sources across the codebase through detailed commit breakdowns and test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 93.28% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/preserve-typed-error-sources

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/src/store/error.rs`:
- Around line 36-58: Change the substring checks in
StoreError::is_database_busy_or_locked to perform case-insensitive matching:
when you compute s = layer.to_string() convert it to lowercase (e.g., s =
s.to_lowercase()) and then check if it contains "locked" or "busy"; keep the
rest of the source-walking loop and logic (is_database_busy_or_locked,
layer/source handling) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b789f5d9-370d-4a9a-9b9a-f3c8e83baad3

📥 Commits

Reviewing files that changed from the base of the PR and between 897e6b5 and 7e87f89.

📒 Files selected for processing (24)
  • src/client.rs
  • src/features/mex.rs
  • src/features/newsletter.rs
  • src/keepalive.rs
  • src/pair_code.rs
  • src/request.rs
  • src/socket/error.rs
  • src/socket/noise_socket.rs
  • src/store/error.rs
  • src/store/persistence_manager.rs
  • src/store/signal.rs
  • src/store/signal_adapter.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/libsignal/src/protocol/error.rs
  • wacore/noise/src/error.rs
  • wacore/noise/src/state.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/download.rs
  • wacore/src/handshake/mod.rs
  • wacore/src/pair_code.rs
  • wacore/src/request.rs
  • wacore/src/store/device.rs
  • wacore/src/store/error.rs
  • wacore/src/store/persistence.rs

Comment thread wacore/src/store/error.rs
Different SQLite drivers/wrappers in the ecosystem are inconsistent about
casing in their diagnostic strings ("database is LOCKED", "Busy", etc.).
The previous substring check `s.contains("locked")` only matched the
specific lowercase form Diesel happens to emit. Lowercase the layer's
Display before checking so the helper is robust to any backend variant.

Regression test covers four casings: "LOCKED", "SQLITE_BUSY: ...", "Busy",
"Locked".
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review nitpicky

@claude

claude Bot commented Apr 26, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 4m 12s —— View job


Nitpicky Review — refactor!: preserve typed error sources

  • Gather context and understand the PR
  • Review error type changes in wacore/ layer
  • Review error type changes in src/ (client) layer
  • Review store/persistence changes
  • Review signal adapter changes
  • Review sqlite-storage changes
  • Post final review

Overall this is excellent work — the principle is sound, the execution is thorough, and the source-chain tests are a strong addition. Below are the nitpicks, roughly ordered from most to least important.


1. Dead variant: StoreError::DeviceNotFound still exists

The PR description lists DeviceNotFound as removed, but it's still defined at wacore/src/store/error.rs:32-33 and has zero usages across the entire workspace. Either remove it as claimed, or update the PR description. A dead variant in a #[non_exhaustive]-free enum forces downstream match arms for a case that can never happen.

wacore/src/store/error.rs:32    #[error("device with ID {0} not found")]
wacore/src/store/error.rs:33    DeviceNotFound(i32),

Fix this →


2. IqError::ParseError(#[from] anyhow::Error) is a footgun

src/request.rs:35ParseError has #[from] anyhow::Error, which means any anyhow::Error produced by ? inside a function returning Result<_, IqError> will silently become a ParseError, even if it has nothing to do with parsing. Meanwhile EncodeError at line 33 correctly uses #[source] (manual construction only).

This isn't new to this PR, but since you're reworking IqError anyway: consider making ParseError also use #[source] and constructing it explicitly at the two call sites (src/request.rs:184,197). This prevents accidental misclassification as the variant set grows.

// src/request.rs
#[error("failed to encode IQ request")]
EncodeError(#[source] anyhow::Error),   // ← manual, good
#[error("failed to parse IQ response")]
ParseError(#[from] anyhow::Error),      // ← auto-converts any anyhow via ?

3. signal_adapter.rs: generic "backend" context strings lose provenance

In src/store/signal_adapter.rs:18-23, the signal_err helper forwards to SignalProtocolError::BackendError(context, ...). Several call sites pass just "backend" as the context string (lines 95, 104, 198, 212, 216, 268), which makes it impossible to distinguish which store operation failed when reading logs. Compare with src/store/signal.rs which passes specific strings like "backend put_identity", "backend get_identity", "store_sender_key", "load_sender_key".

Consider passing more descriptive context strings through the adapter too:

// Instead of:
.map_err(signal_err("backend"))

// Consider:
.map_err(signal_err("session_adapter::load_session"))

4. EncryptSendError wraps anyhow::Error — inconsistent with the PR's principle

src/socket/error.rs:33-39EncryptSendError stores source: anyhow::Error, which is essentially the same string-flattening anti-pattern this PR is eliminating (anyhow erases the concrete type unless you downcast). The PR adds IqError::EncryptSend(#[from] EncryptSendError), so the chain is IqError → EncryptSendError → anyhow::Error → ???.

This is likely out of scope for this PR since EncryptSendError predates it, but worth noting for a follow-up — the encrypt/send pipeline could preserve typed sources the same way the rest of this series does.


5. wacore::store::persistence.rs — no consecutive-failure halting

The wacore PersistenceManager::run_background_saver (lines 148-186) has no consecutive-failure counter or halt mechanism. The high-level crate's version at src/store/persistence_manager.rs:157 has MAX_CONSECUTIVE_FAILURES = 10 and saver_halted. This means the wacore saver will silently retry forever on a persistently-broken backend, logging errors each interval.

Not introduced by this PR, but the persistence changes here touched both files — might be worth unifying or at least adding a TODO.


6. is_retriable_sqlite_error in sqlite_store vs is_database_busy_or_locked

storages/sqlite-storage/src/sqlite_store.rs:41-49 has is_retriable_sqlite_error which does the same substring matching on Diesel error messages but without case-insensitive comparison. The new StoreError::is_database_busy_or_locked() at wacore/src/store/error.rs:48 correctly lowercases. These two helpers should be consistent — if a driver returns "LOCKED", the store-level helper catches it but the sqlite retry loop won't.

// sqlite_store.rs:44-45 — case-sensitive, could miss "LOCKED" or "Busy"
let msg = info.message();
msg.contains("locked") || msg.contains("busy")

Fix this →


7. Missing source-chain test for StoreError::Connection / Migration / Serialization

The test suite has database_preserves_typed_source_via_downcast for the Database variant, but the other three box-carrying variants (Connection, Migration, Serialization) don't have equivalent tests. Since the PR's stated goal is verifying source chains, and all four variants underwent the same String → Box<dyn Error> change, it'd be consistent to cover them all.


8. NoiseError::CiphertextTooShort — only checked in decrypt_into, not decrypt

wacore/noise/src/state.rs:183 checks ciphertext.len() < TAG_LEN and returns CiphertextTooShort, but decrypt at line 171 doesn't. The AES-GCM implementation will presumably reject it, but it'll produce a generic Decrypt(CryptoProviderError) rather than the descriptive CiphertextTooShort. Not a bug, but inconsistent.


9. Minor: PairError variants both use #[from] — error ambiguity risk

src/pair_code.rs:67-73:

pub enum PairError {
    #[error(transparent)]
    PairCode(#[from] PairCodeError),
    #[error("pair-code IQ request failed")]
    RequestFailed(#[from] IqError),
}

The transparent on PairCode means PairError's Display delegates to PairCodeError's Display, but source() also delegates transparently. This is correct and tested. Just noting: if PairCodeError ever gains an IqError-wrapping variant, the two #[from] impls could create ambiguous conversion paths. The current design is clean though.


10. Minor: RetriesExhausted { op: String } could be &'static str

wacore/src/store/error.rs:23-24 — most call sites pass string literals ("identity_write (after 6 attempts)"), but the format!() in sqlite_store.rs:652 forces it to String. If you wanted to avoid the allocation on the hot retry-exhaustion path, you could make op a Cow<'static, str> — though this is genuinely minor and probably not worth the ergonomic cost.


Summary

Strong refactoring. The typed source chains are correct, well-tested, and the principle is applied consistently. The main actionable items are:

  1. Remove dead DeviceNotFound variant (or update the PR description)
  2. Make is_retriable_sqlite_error case-insensitive to match the new is_database_busy_or_locked
  3. Consider #[source] over #[from] for IqError::ParseError to prevent silent misclassification

Everything else is advisory or follow-up material.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
wacore/src/store/persistence.rs (1)

176-178: 🧹 Nitpick | 🔵 Trivial

Consider adding consecutive failure tracking like the other PersistenceManager.

I see the module comment says this should be consolidated with src/store/persistence_manager.rs. But until that happens, this implementation just logs save errors and keeps going (lines 176-178). The other implementation has MAX_CONSECUTIVE_FAILURES = 10 and a saver_halted flag to prevent silent data loss.

If the backend is persistently broken, this version will log errors forever without surfacing the problem to the application. That's not ideal for reliability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/store/persistence.rs` around lines 176 - 178, The background save
loop in the method that calls this.save_to_disk().await currently only logs
errors and continues; add logic mirroring PersistenceManager by introducing a
consecutive failure counter and a MAX_CONSECUTIVE_FAILURES threshold and a
saver_halted flag so persistent failures stop further attempts and surface the
error; increment the counter on Err(e) from this.save_to_disk(), reset it on
success, and when the counter reaches MAX_CONSECUTIVE_FAILURES set
saver_halted=true and log/return a fatal error (or otherwise notify the
application) to avoid silent data loss—update the same function that contains
the this.save_to_disk() call and use the existing names
(MAX_CONSECUTIVE_FAILURES, saver_halted, save_to_disk) so it aligns with
persistence_manager.rs behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@wacore/src/store/persistence.rs`:
- Around line 176-178: The background save loop in the method that calls
this.save_to_disk().await currently only logs errors and continues; add logic
mirroring PersistenceManager by introducing a consecutive failure counter and a
MAX_CONSECUTIVE_FAILURES threshold and a saver_halted flag so persistent
failures stop further attempts and surface the error; increment the counter on
Err(e) from this.save_to_disk(), reset it on success, and when the counter
reaches MAX_CONSECUTIVE_FAILURES set saver_halted=true and log/return a fatal
error (or otherwise notify the application) to avoid silent data loss—update the
same function that contains the this.save_to_disk() call and use the existing
names (MAX_CONSECUTIVE_FAILURES, saver_halted, save_to_disk) so it aligns with
persistence_manager.rs behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d4d95d8f-b380-46ad-a5f1-abfa8ee9e80f

📥 Commits

Reviewing files that changed from the base of the PR and between 7e87f89 and ef16c7e.

📒 Files selected for processing (3)
  • src/store/persistence_manager.rs
  • wacore/src/store/error.rs
  • wacore/src/store/persistence.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant