refactor(errors): make the public error surface recoverable without string matching - #1100
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI (base), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (35)
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe PR adds public typed error-chain recovery utilities, standardizes wrapped error display and source preservation across ChangesError surface and chain recovery
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant AppStateKeyShare
participant ErrorChainExt
participant ErrorChain
AppStateKeyShare->>ErrorChainExt: is_transport_unavailable(cause)
ErrorChainExt->>ErrorChain: walk sources and downcast transport errors
ErrorChain-->>ErrorChainExt: matching or non-matching error
ErrorChainExt-->>AppStateKeyShare: reconnect decision
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Filename | Overview |
|---|---|
| src/error.rs | New file: ErrorChainExt trait with blanket impls for concrete types and three dyn Trait shapes; Sources iterator; ServerRejection view struct. Design is sound — the three impl blocks are non-overlapping (blanket has implicit Sized bound, dyn impls cover unsized shapes). |
| tests/error_surface.rs | New integration test file with source-scanning tests using syn::parse_file, chain-preservation tests, regression tests for the two original reports, and scanner self-tests covering blank-line attribute separation and unbalanced braces in doc comments. |
| wacore/src/request.rs | Adds is_transport_unavailable() (pub, matches!) and is_timeout() (pub, exhaustive match) to wacore::request::IqError. |
| src/request.rs | Adds pub(crate) is_timeout() with exhaustive match to crate::request::IqError. pub(crate) is correct since it is only consumed from src/error.rs within the same crate. |
| src/client.rs | Adds ConnectError::is_timeout() (pub, exhaustive match) delegating to HandshakeError::is_timeout() for the Handshake variant. All transparent conversions applied correctly. |
| src/handshake.rs | Adds HandshakeError::is_timeout() (pub, exhaustive match). Required by both ErrorChainExt::is_timeout() and ConnectError::is_timeout(). |
| src/send/mod.rs | SendError::Client gains #[source] — the only variant in the PR needing it explicitly because it had neither #[from] nor #[source] before. |
| src/message/special.rs | Replaces a 3-way inline downcast walker with ErrorChainExt::is_transport_unavailable(). Behaviour-identical. |
| src/lib.rs | Adds pub mod error and re-exports ErrorChainExt, ServerRejection, Sources. anyhow is already pub use'd, making whatsapp_rust::anyhow::Error valid in the doc example. |
| Cargo.toml | Adds syn 3.0 to dev-dependencies for tests/error_surface.rs. Test-only; no production dependency added. |
Reviews (3): Last reviewed commit: "test(errors): parse the sources instead ..." | Re-trigger Greptile
📦 Binary size report
.text per crate
Top movers (cargo-bloat attribution)
Baseline: |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/error.rs`:
- Around line 1-58: Trim the module-level documentation above ErrorChainExt to a
concise statement of its purpose, a brief scope summary, and one representative
usage example. Remove detailed explanations of error-chain internals,
crate-specific type inventories, cost claims, and the separate anyhow example;
keep only guidance needed to understand and use the extension trait.
In `@tests/error_surface.rs`:
- Around line 79-100: Update surface_has_no_transparent_error_attribute to
detect #[error(transparent)] even when followed by a trailing comment or other
same-line text, while continuing to scan all Rust sources and report the
offending location. Replace the exact trimmed-line comparison with a robust
match for the attribute itself.
- Around line 44-77: The regression guard in tests/error_surface.rs must stop
using the text heuristics in preceding_attributes(), enum_body(), and
is_error_enum(). Add syn as a development dependency, parse the Rust source into
an AST, and inspect public ItemEnum declarations and their attributes to
identify error enums and validate non_exhaustive reliably, including empty enums
and unusual formatting.
🪄 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 (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 9fbe3406-bd14-474a-819b-5be24bc4c786
📒 Files selected for processing (31)
src/bot.rssrc/client.rssrc/client/voip.rssrc/error.rssrc/features/blocking.rssrc/features/chat_actions.rssrc/features/chatstate.rssrc/features/community.rssrc/features/contacts.rssrc/features/groups.rssrc/features/media_reupload.rssrc/features/newsletter.rssrc/features/polls.rssrc/features/presence.rssrc/features/profile.rssrc/features/signal.rssrc/features/stanza.rssrc/features/tctoken.rssrc/lib.rssrc/message/special.rssrc/pair_code.rssrc/plugins/events.rssrc/plugins/mod.rssrc/send/mod.rstests/error_surface.rswacore/src/appstate_sync.rswacore/src/download.rswacore/src/iq/chatstate.rswacore/src/iq/dirty.rswacore/src/pair_code.rswacore/src/shortcake.rs
…tring matching
A consumer that classifies failures by walking `source()` could not recover
the code behind a `403` from a group operation: `GroupError::Iq` was
`#[error(transparent)]`, and `transparent` delegates `source()` to the wrapped
error's *own* source. `IqError::ServerError` is a leaf, so the chain ended
before it and the typed node was gone. Only the `Display` text was left.
`#[error("{0}")]` renders byte-for-byte the same text and keeps the wrapped
error as `source()`, so the fix costs no message change anywhere. All 46
occurrences are converted, including the `Internal(anyhow::Error)` ones, which
had the same hole whenever the `anyhow` value was built without `.context()`
(as `context_impl.rs` does when it wraps a `ServerErrorCode`).
Losslessness alone still leaves the consumer writing a chain walk and knowing
that three different types can carry a server rejection. `ErrorChainExt` is
that walk written once, as an extension trait with a blanket impl over
`std::error::Error`, so a domain added later answers the same questions
without implementing anything. Only facts the crate already distinguishes are
exposed: server rejection, timeout, transport loss and store failure. No
"invalid input" or "internal" category is invented, and MEX extension codes
stay out of `server_rejection()` because they are a different code space.
Two source-scanning tests keep this from regressing: one fails on any
`#[error(transparent)]`, the other on any error enum missing
`#[non_exhaustive]`. Both were verified to fail when the violations are
reintroduced.
… one owner `is_timeout()` only matched the two `IqError::Timeout` variants, so a connect or handshake that ran out of time answered `false`. Both are distinctions the crate already makes (`ConnectError::Timeout`, `HandshakeError::Timeout`), so recovering them is in scope; the negative cases of the same flows are pinned alongside. `is_transport_unavailable()` restated `wacore`'s transport variant list inline while delegating to a method for the other two types, so a new transport variant there would have diverged in silence. `wacore::request::IqError` now owns that judgement like its siblings do. Also documents that a wrapping variant renders what it wraps, so joining a whole chain repeats the text, and that the no-`transparent` scan is a blanket policy covering private enums rather than an accident of the reported symptom.
…meout one owner The two surface guards read the crate sources as text, and two shapes slipped past them. A blank line between `#[non_exhaustive]` and the declaration hid the attribute, which was merely a loud false positive. Worse, an unbalanced brace inside a doc comment truncated the enum body by brace balance, so the enum stopped looking like an error enum and a public one missing `#[non_exhaustive]` passed the guard in silence. Both were reproduced before changing anything. That guard is what backs the claim that the pattern cannot be forgotten in a later PR, so it must not fail open. It now parses with `syn`, which is already in the lockfile via `wacore/derive`, so this adds a dependency edge and no new crate. Seven fixtures pin the shapes that used to defeat it, including the two above, a wrapped header, a private enum and a non-error enum whose prose and variant name both mention Error. `is_timeout()` had the problem that `is_transport_unavailable()` was just fixed for: it restated variant lists at the call site for four types, so a second timeout variant anywhere would have diverged unnoticed. Each type now owns the judgement, and each match is exhaustive so a new variant has to be classified rather than silently defaulting to false.
66a16c5 to
e2c7b8a
Compare
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
Summary
A consumer classifying failures by walking
std::error::Error::source()got a403back from a group operation and could recover neither the code nor the type:GroupError::Iqis#[error(transparent)], andtransparentdelegatessource()to the wrapped error's own source.IqError::ServerErroris a leaf, so the chain ended before it and the typed node vanished, leaving onlyDisplaytext to parse. The same held for the409on a group description update.The fix turns out to be free.
#[error("{0}")]renders byte-for-byte the same text astransparentwhile keeping the wrapped error reachable assource(), so making the chain lossless changes no message anywhere. That is done for all 46 occurrences.Losslessness alone still leaves every consumer writing a chain walk and knowing that three different types can carry a server rejection (
wacore::request::IqError,crate::request::IqError, andServerErrorCode, which exists only to move one across a crate boundary).ErrorChainExtis that walk written once, as an extension trait with a blanket impl overstd::error::Error— so a domain error added next month answers the same questions without implementing anything.Audit
53 public error enums (38 in
src/, 15 inwacore/src/) and 46#[error(transparent)]occurrences, 45 of them on public enums and one on the privatePluginCallbackError.Classifying the 46 against "does it hide a typed leaf":
anyhowtransparentin the crate falls here — each wraps a type whose common variants are leaves (IqError,MexError,StoreError,ClientError::NotConnected,SignalProtocolError, …).Internal(#[from] anyhow::Error)transparentskips theanyhowhead, so a bareanyhow::Error::new(leaf)loses the leaf outright; only a.context()-wrapped value survives.context_impl.rs:40builds exactly the bare form around aServerErrorCode.So all 46 were converted, not just the 33. I verified the
thiserrorsemantics empirically rather than from the docs, for both the.context()and bare cases, before touching the repo.#[non_exhaustive]: 4 of the 53 lacked it (ShortcakeError,PairCodeError,ChatstateParseError,DirtyBitParseError). Added.anyhowon the surface: 31 public variants carryanyhow::Errorand all 31 stay. The policy this PR sets is that they stop being dead ends rather than being erased: after the change the head of theanyhowchain is exposed assource()and is downcastable, so aServerErrorCodeorIqErrorburied in one is recoverable by type. What a consumer may assume about anInternalvariant is only that: it is a failure with no dedicated variant yet, whose chain is walkable. Not eradicating them here keeps this batch to the shape of errors, which is what it is about.Two
IqErrortypes: left as-is. They are not trivially unifiable (thesrcone addsSocket,EncryptSend,ClientState,Disconnected,EncodeError,ParseError), and collapsing them is a separate refactor touching ~49 match sites. What made the duplication a consumer problem was having to downcast both;ErrorChainExtabsorbs that, and a test pins that both answer identically.Changes
#[error(transparent)]with#[error("{0}")], adding#[source]to the one field that had neither#[from]nor#[source](SendError::Client). Each error's ownDisplayoutput is unchanged, which a test asserts.Displayis identical, but the chain now renders differently: a wrapping variant prints what it wraps, so code that concatenates every node sees the same sentence repeated.CommunityError::Group(GroupError::Iq(..))is three nodes rendering one sentence three times. Avoiding that is precisely whattransparentbought, and it is the cosmetic price of keeping the wrapped error downcastable. Consumers that join the chain (adisplay_chainhelper, atracinglayer printing causes) should print the innermost cause or collapse equal neighbours.src/error.rs:ErrorChainExt,ServerRejection<'a>,Sources<'a>, re-exported from the crate root. Read-only view over the existing errors; no new error type, no parallel hierarchy.#[non_exhaustive]to the 4 error enums missing it (ShortcakeError,PairCodeError,ChatstateParseError,DirtyBitParseError). Breaking for anyone matching them exhaustively outside the crate, which fails to compile withE0004. Migration: add a_ => {}arm.PairCodeErroris the one with a known external matcher, so expect that build to need the arm.wacore::request::IqErroranis_transport_unavailable()method (new public API, non-breaking) and call it, instead of restating its transport variant list at the call site. Its two sibling types already owned that judgement as a method; the inline copy would have diverged in silence if a transport variant were added.ConnectError,HandshakeErrorand bothIqErrortypes anis_timeout()method, each an exhaustive match, instead of restating variant lists at the call site. Same reasoning as the transport fix above: a second timeout variant would otherwise have diverged unnoticed, and an exhaustive match forces a new variant to be classified rather than silently defaulting to false.is_timeout()also recoversConnectError::TimeoutandHandshakeError::Timeout. Both are timeouts the crate already distinguishes, so this recovers a distinction rather than inventing one; before, a connect that ran out of time answeredfalse.src/message/special.rswithErrorChainExt::is_transport_unavailable. Behaviour-identical: the only downcast target it adds iswacore::request::IqError, which cannot appear in that chain (the sole conversion site,src/request.rs:116, maps it intocrate::request::IqErrorimmediately). The 5 existing assertions for that function pass untouched.pair_error_paircode_transparent_walks_to_curve_error— see below.The one test that had to change
src/pair_code.rshad a test asserting the lossy behaviour by name: it walked one hop fromPairError::PairCodeand expected to land onCurveError, "becausePairError::PairCodeistransparent". With the wrapper no longer erasing itself, hop 1 is thePairCodeErrorandCurveErroris hop 2. Rewritten to assert the stronger chain, keeping itsDisplayassertion as-is (it still passes, which is itself evidence the text did not move). No other test in the repo needed adapting.Guarantees
Given any public error from this crate, without string matching and without knowing the concrete domain enum:
server_rejection()returns the code, text, XMPP error class and backoff of an IQ-level rejection, wherever in the chain it sits and whichever of the three carrier types holds it.is_timeout()answers the same way, and covers a connect or handshake step that never completed as well as a request that got no answer.is_transport_unavailable()andstore_failure()answer the same way.anyhow-carried case.sources()exposes the raw chain for anything not modelled above.anyhow,serdeor extra runtime is required to use any of this.Out of contract, deliberately: there is no "invalid input", "protocol violation" or "internal" category. Each domain spells those as its own
InvalidRequest(String)-style variant with no shared representation, so exposing them would mean inventing a taxonomy rather than recovering one.MexError::ExtensionErroris not reported byserver_rejection()either — itscodeis a GraphQL extension code, a different space from the IQcodeattribute, and merging them would make the number mean two things. A negative test pins both.Cost
size_of: unchanged for all 51 public error types measurable under--all-features(checked before/after; the diff is empty). Expected, since the change is attribute-only and touches no field. Nothing needed boxing.format!,to_string()or allocation moved out of a failure branch.ErrorChainExtruns only when a caller asks, on an error value it already holds; it borrows and allocates nothing. The single production call site isspecial.rs, already inside an error branch, and it replaced a walk that did strictly more downcasts.binary-sizejob, which measures against themainbaseline — more trustworthy than two local fat-LTO builds. I will report the delta from the PR comment.Validation
Both surface guards were verified to actually fail: reintroducing one
transparentand removing one#[non_exhaustive]fails them, plus 7 behavioural tests independently.They parse the sources with
synrather than scanning them as text, which is a change of mechanism worth calling out. The text version fell to two shapes, both reproduced before being fixed: a blank line between#[non_exhaustive]and the declaration hid the attribute (a loud false positive), and an unbalanced brace inside a doc comment truncated the enum body by brace balance, so the enum stopped looking like an error enum and a public one missing#[non_exhaustive]passed the guard in silence. A guard that backs a "cannot be forgotten later" claim must not fail open. Seven fixtures now pin the shapes that used to defeat it.synis added as a dev-dependency and is already inCargo.lockviawacore/derive, so this adds a dependency edge and no new crate, and nothing reaches the shipped library.The no-
transparentscan is deliberately a blanket policy rather than a narrow fix: it covers private enums too (the privatePluginCallbackErrorwas converted for that reason), because today's private error is tomorrow's public one and the attribute is invisible at the point where it hurts. A future case where erasure is genuinely wanted should be argued in review, not waived silently.Semver Checks is red, as it is on
main: it compares against the last published release, so it carries every change since then. Of the 10 enums it lists underenum_marked_non_exhaustive, this PR is responsible for 3 (ChatstateParseError,DirtyBitParseError,PairCodeError;ShortcakeErroris the fourth marker added here but does not appear in its output). The other 7 (AppStateSyncError,CallAction,HistorySyncError,IqError,MediaDecryptionError,ReceiptType,StoreError) are pre-existing drift:git diff main...HEADshows this branch adds#[non_exhaustive]in exactly four files, none of them theirs. The remaining lint categories are likewise untouched by this branch, and the additions it does make (the newis_timeout/is_transport_unavailablemethods and theerrormodule) are non-breaking.Full matrix (incl. wasm32) left to CI.