refactor!: preserve typed error sources across the workspace - #597
Conversation
…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.
📝 WalkthroughSummary by CodeRabbit
WalkthroughThis 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
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
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)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (24)
src/client.rssrc/features/mex.rssrc/features/newsletter.rssrc/keepalive.rssrc/pair_code.rssrc/request.rssrc/socket/error.rssrc/socket/noise_socket.rssrc/store/error.rssrc/store/persistence_manager.rssrc/store/signal.rssrc/store/signal_adapter.rsstorages/sqlite-storage/src/sqlite_store.rswacore/libsignal/src/protocol/error.rswacore/noise/src/error.rswacore/noise/src/state.rswacore/src/appstate_sync.rswacore/src/download.rswacore/src/handshake/mod.rswacore/src/pair_code.rswacore/src/request.rswacore/src/store/device.rswacore/src/store/error.rswacore/src/store/persistence.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".
|
@claude review nitpicky |
|
Claude finished @jlucaso1's task in 4m 12s —— View job Nitpicky Review —
|
There was a problem hiding this comment.
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 | 🔵 TrivialConsider 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 hasMAX_CONSECUTIVE_FAILURES = 10and asaver_haltedflag 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
📒 Files selected for processing (3)
src/store/persistence_manager.rswacore/src/store/error.rswacore/src/store/persistence.rs
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=400vs timeout vs disconnect).Principle applied: um erro só vira
Stringno 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)
refactor(pair-code)!— SplitPairCodeError. wacore variants kept (validation, key derivation, AEAD), high-levelPairErrorintroduced wrappingPairCodeError + IqError.CryptoError(String)exploded into typed variants forCurveError(key parse, key agreement) andCryptoProviderError(AES-GCM).refactor(socket,noise,iq)!—NoiseError::CryptoError(String)→Encrypt/Decrypt(#[source] CryptoProviderError).SocketError::Crypto/NoiseHandshakeremoved; newCipher(#[from] NoiseError)andMarshal(#[source] BinaryError).IqErrorgotEncryptSend(#[from])+ClientState(#[source])for the catch-all that previously flattenedClientError. Two dead variants (SocketError::NoiseHandshake,wacore::request::IqError::Network) deleted.refactor(media-download)!—MediaDecryptionError::Decryption(String)→Decryption(#[source] DecryptionError)+ newMac(#[source] CryptoError).refactor(mex)!—MexError::PayloadParsing(String)kept for the 15+ descriptive call sites; one site that wrapped a realJidErrorgot moved to a newInvalidJid(#[from] JidError)variant.refactor(libsignal,store)!— AddedSignalProtocolError::BackendError(&'static str, #[source] Box<dyn Error+Send+Sync>)to vendored libsignal (ApplicationCallbackErrorrequiresUnwindSafe, whichStoreError::Backenddoesn't satisfy). 4 call sites insignal_adapter/signal.rsswitched. Documented divergence from upstream Signal.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 insqlite-storageupdated. Newis_database_busy_or_locked()helper walks the source chain — replaces the substringmatches!(... msg.contains(\"locked\"))anti-pattern inclient.rs:2510. Retireddb_err()helper (was wrap-already-wrapped — backend already returnedStoreError).Breaking changes
Surface-level (anyone matching variants):
PairCodeError::CryptoError,PairCodeError::RequestFailedremoved; replaced by typed variants and the newPairErrorwrapper.Client::pair_with_codereturnsPairError(notPairCodeError).SocketError::Crypto,SocketError::NoiseHandshakeremoved.NoiseError::CryptoErrorremoved;NoiseErrorno longerClone.wacore::request::IqError::Networkremoved.IqErrorgainedEncryptSend+ClientState; pattern matching needs to cover them.MediaDecryptionError::Decryption(String)now carriesDecryptionError.StoreError::{Database, Connection, Migration, Serialization}carry typed sources, not Strings;Backend,NotFound,DeviceNotFoundremoved.db_errhelper removed.SignalProtocolErrorgot a newBackendErrorvariant (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 viatracing::error!(error = ?e)or anyhow).Source-chain tests
Each touched enum has a test verifying
error.source()returns the expected concrete type viadowncast_ref:wacore::pair_code::tests::invalid_primary_ephemeral_key_preserves_curve_sourcewacore::pair_code::tests::bundle_aead_preserves_crypto_provider_sourcewhatsapp_rust::pair_code::tests::pair_error_request_failed_preserves_iq_sourcewhatsapp_rust::pair_code::tests::pair_error_paircode_transparent_walks_to_curve_errorwhatsapp_rust::socket::error::tests::cipher_preserves_noise_source_through_socket_error(two-hop downcast)whatsapp_rust::socket::error::tests::marshal_preserves_binary_error_sourcewacore::download::tests::media_decryption_decryption_preserves_aes_cbc_sourcewacore::download::tests::media_decryption_mac_preserves_crypto_error_sourcewhatsapp_rust::features::mex::tests::invalid_jid_preserves_jid_error_sourcewhatsapp_rust::features::mex::tests::request_preserves_iq_error_sourcewacore_libsignal::protocol::error::tests::backend_error_preserves_typed_source_via_downcastwacore::store::error::tests::database_preserves_typed_source_via_downcastwacore::store::error::tests::is_busy_or_locked_walks_chain(+ negative case)Acceptance criteria
grep -rn 'map_err.*\.to_string()' src wacore/srcreturns only the two annotated// reason:lines inwacore/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-testscargo clippy --workspace -- -D warningscargo test -p e2e-tests(requires mock server — not run locally)