Skip to content

Latest commit

 

History

History
21 lines (18 loc) · 17.8 KB

File metadata and controls

21 lines (18 loc) · 17.8 KB

error-handling — notes

Hand-written. ../harvested/error-handling.md is what the documents say; this file is what the implementation found, and it wins. Each entry names the harvested entry it answers by that entry's stable key.

Conflicts

  • The SDK's error root is a module included by every error class, not a base exception class, and there is no Assert facade. Resolves error-handling/51261878 and error-handling/75571c73. Both rules were routed here by phase 0's design, which shipped no production code for them to bite on. The first rule's purpose — one root a caller can rescue broadly, with domain trees hanging off it — is adopted; its mechanism, a base class, is unsatisfiable here, and the forcing requirement is XCUT-4: "transport errors ... belong to the runtime's I/O-error family so existing I/O catch sites keep matching", which in Ruby means Dexpace::TransportError < ::IOError. Ruby has single inheritance, so a class root would make XCUT-4 and the one-root rule mutually exclusive, and the choice cannot be deferred past the phase that creates the root. What the SDK does instead: Dexpace::Error is a module, rescue Dexpace::Error catches every including class because rescue matches with Module#===, and each concrete error picks the Ruby superclass its requirement names — Dexpace::InvalidArgumentError < ::ArgumentError in phase 1, Dexpace::TransportError < ::IOError in phase 8 — while docs/sdk-design-ruby/05-pipeline-architecture.md's #suppressed array and #full_message override live on the module and arrive with the recovery chain in phase 4 (the suppressed-trail deferral, phase 4b Task 1). The hierarchy stays two levels deep as error-handling/61ab4fb6 asks. The second rule fails on its own premise a second way: Assert::InvariantViolation presupposes the Assert facade that ../notes/assertions.md already replaced with the domain model's own validation helper, so a programmer error here raises through that helper's one class and one message form (SEAM-29, HTTP-4) rather than through a facade this codebase does not have, and error-handling/ffdf6f4f's "ArgumentError signals caller mistakes; raise it from validation helpers, do not rescue it in business logic" is exactly the discipline that replaces it. One consequence worth stating because it is a trap: Dexpace::ArgumentError is never defined, in this or any later phase — the name would shadow ::ArgumentError for every file inside module Dexpace, so a bare rescue ArgumentError written in core would silently stop catching Ruby's own. The styleguide-amendment alternative (rewording rule 8's "one project-level base exception class" as "one project-level root, a class or a module a caller can rescue") was considered and not taken; this is recorded as an SDK deviation instead (phase 1, P1-2 and P1-3). review · docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md · high · sha:manual-phase1-error-root

Superseded

  • The suppressed trail must be rendered through #detailed_message, not #full_message: a #full_message override is invisible to Ruby's own uncaught-exception printer. Supersedes error-handling/34f54b5e ("Core's error root Dexpace::Error carries a #suppressed array, frozen once populated, with #full_message overridden to render the suppression trail"), whose rule — one core-owned trail on the error root, rendered wherever the error is displayed — is right, and whose mechanism renders it in the one place nobody looks. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The fact: a module defining full_message(**kw) and included into a StandardError subclass renders the trail correctly when #full_message is called explicitly, and contributes nothing at all to the default uncaught-exception report — raise on such an error prints the plain file:line:in '<main>': boom (MyErr) on all three interpreters, with no trail. The default printer is implemented in C and does not dispatch to a Ruby-level full_message. It does dispatch to #detailed_message (added in 3.2, so present on this port's floor): the same module defining detailed_message(**kw) instead puts the trail into the uncaught-exception report on all three. And Ruby's own Exception#full_message calls #detailed_message internally, verified on all three, so overriding detailed_message alone satisfies both paths while overriding full_message alone satisfies neither of the ones that matter — an explicit e.full_message call in a logger being the only route the latter reaches. What the SDK does instead, per docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md: Dexpace::Error — a module, per this file's first entry — carries #suppressed and overrides #detailed_message, and phase 4's 4b decides whether #full_message is also overridden (it need not be). This is the content of the suppressed-trail deferral (phase 4b Task 1) and the carrier of the Hooks.notify dropped-failures one (phase 4b Task 2), so it changes what phase 4 builds rather than being noted after the fact. This entry also corrects one clause of the phase-1 note in this file's ## Conflicts section, which says the #suppressed array "and #full_message override live on the module and arrive with the recovery chain in phase 4 (the suppressed-trail deferral, phase 4b Task 1)": everything in that sentence stands except the method name. It is corrected here rather than rewritten there because that note's key is cited by docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md, and a note's key is digested from its text, so editing it in place would retire a live citation. A --origin note reader who meets the ## Conflicts entry first must read this one as the later word on the mechanism. error-handling/5a8298f6 (Dexpace.attach_suppressed with the self-suppression guard) and error-handling/87ce2196 (#cause is never used for the trail) are unaffected and are adopted verbatim. review · docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md · high · sha:manual-phase4-detailed-message
  • The #cause cycle XCUT-9 guards against is not reachable by the route the design names — Ruby actively rejects it — and testing that route would suggest the guard is unnecessary. Supersedes error-handling/11c6f36c ("Ruby does not prevent cause-chain cycles because #cause is settable through Exception#exception, and application re-raise chains can close on themselves, turning an infinite cause walk inside a rescue handler into an unkillable hang"), whose conclusion — the walk must be cycle-safe — stands, and whose two stated mechanisms are both false. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The facts, all four uniform across the range. raise y, cause: x when x.cause is already y raises ArgumentError: circular causes — the VM refuses to close a two-node chain. raise s, cause: s is accepted and leaves s.cause nil; so does re-raising the same object while it is $!. And Exception#exception("msg") returns a new object whose #cause is nil, so it is not a route to setting a cause at all. The route that does work, and that the design does not name: a caller-defined #cause override. class Loopy < StandardError; def cause = self; end self-cycles, and two wrapper objects each returning the other cycle two ways — both verified on all three. That is the shape core cannot control, because Dexpace.each_cause walks caller-supplied errors: a third-party transport adapter's error class, or an application's own wrapper. XCUT-9 therefore stays a live MUST and error-handling/5a7d53ab's equal?-tracked visited set stays necessary; only the test that proves it changes, and a phase-4 implementer who builds the cycle the way the superseded entry describes gets an ArgumentError and could reasonably conclude Ruby prevents cycles and drop the guard. error-handling/c1fa7ee8 (the XCUT-9 requirement itself) and error-handling/71ef8cb1 (the rule) are unaffected and are adopted verbatim. review · docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md · high · sha:manual-phase4-cause-cycle-route
  • The reason Dexpace.each_cause must track by reference identity is Ruby's own Exception#==, not Data's structural equality — and the container that gets it right is {}.compare_by_identity, not an Array and not a Set. Supersedes error-handling/5a7d53ab ("Core provides one Dexpace.each_cause(error) enumerator that tracks visited objects by equal? rather than ==, because core's Data-based errors define structural equality that would otherwise truncate a legitimate chain of two distinct errors carrying identical fields"), whose conclusion — track by equal? — is right and load-bearing, and whose stated reason is false about this codebase: core's errors are not Data at all. Dexpace::Error is a module included by ordinary StandardError subclasses (this file's ## Conflicts entry, phase 1's P1-2), so no Data-generated == is anywhere near a cause chain. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The real mechanism: Exception#== is structural by Ruby's own definition — same class, same message, same backtrace — so two distinct un-raised errors of one class with one message are == (true on all three), and so are two raised at the same source line, while #eql? and #hash are Object's and therefore identity. The truncation the entry warns about is therefore reachable through Ruby's semantics with no Data involved. The consequence the entry does not reach, and it decides an implementation: a visited Array with #include? uses == and truncates — measured, a two-node chain of ==-equal errors yields 1 where the correct answer is 2. How that pair is built is part of the finding, because the obvious construction hides the bug on the supported floor. Chaining two same-message errors by raising — raise the parent, then raise the child from the rescue so Ruby links them — makes them == on 3.4.10 and 4.0.6 and not == on 3.2.11, because 3.2's backtrace carries a rescue in <method> frame the parent's does not and Exception#== compares the backtrace; measured, an Array-tracked walk over that pair yields 2, 1, 1 across 3.2.11 / 3.4.10 / 4.0.6, so the truncation is simply absent on the floor. The construction that is uniform is two never-raised instances, whose backtraces are both nil and which are therefore == on all three, chained by a caller-defined #cause override: measured Array 1 / identity 2 on all three, and with hash/eql? also overridden, Set 1 / identity 2 on all three. A cycle-guard or identity-tracking test built the raise way is green on 3.2.11 against an Array-tracked implementation, which is the matrix column that exists to catch it. A Set or a plain Hash uses #eql?/#hash, which for a bare Exception are identity and therefore accidentally correct — but a caller-supplied error class that overrides hash/eql? structurally defeats them, measured Set[a].include?(b) == true on all three, and Dexpace.each_cause walks caller-supplied errors by construction. Only {}.compare_by_identity ignores both overrides; measured false for the same pair on all three. What the SDK does, per docs/work/mvp/phase4/phase4b/2026-09-08-phase4b-recovery-primitives-design.md: Dexpace.each_cause's visited set is a Hash built with #compare_by_identity, never an Array, never a Set, and core therefore requires nothing new — set is on phase 0's allowlist but is not reached. error-handling/c1fa7ee8 (XCUT-9 itself) and error-handling/71ef8cb1 (the rule) are unaffected and adopted verbatim, and so is this file's own preceding entry on which route builds the cycle at all; this entry answers a different question — which container makes the tracking true. review · docs/work/mvp/phase4/phase4b/2026-09-08-phase4b-recovery-primitives-design.md · high · sha:manual-phase4b-identity-container
  • The suppressed trail cannot live only on Dexpace::Error, because every primary RECOV-12, Dexpace.close_quietly's disposal routes (phase 4b Task 2, phase 5b Task 14) and Hooks.notify's attached handler failures (phase 4b Task 2) hand it is a caller-supplied exception; it lives on a separate Dexpace::Suppressible module that Dexpace::Error includes and attach_suppressed extends onto anything else. Supersedes error-handling/5a8298f6 ("One Dexpace.attach_suppressed(primary, secondary) helper skips attaching an exception to itself, implementing the self-suppression guard"), whose self-suppression clause stands verbatim and whose silence about the primary's type is what needed answering: with the trail defined on Dexpace::Error alone, the helper raises NoMethodError the first time a third-party step throws, which is RECOV-12's ordinary case rather than an edge one — a recovery step is caller-supplied and its throwable is whatever it raised, and the Hooks.notify deferral's own carrier is a hook failure that phase 2's tests raise as a bare ::IOError. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The facts. Object#extend on an exception instance puts the module in that object's singleton class, and a #detailed_message defined there reaches the default uncaught-exception printer exactly as an included one does — the trail prints under raise on all three — while super still reaches Ruby's own, so a NoMethodError's did_you_mean suggestion survives (measured: kept, all three). extend costs roughly four times a bare exception allocation (5 ms vs 22-27 ms per 50 000) and only on an error path. It raises FrozenError on a frozen exception, and so does the ivar write, on all three. And rescue M does match a module reached through a singleton class, which is why the trail must not be Dexpace::Error itself: extending a third-party IOError with the rescue root would make rescue Dexpace::Error catch errors the SDK did not raise. What the SDK does: Dexpace::Suppressible carries #suppressed and #detailed_message; Dexpace::Error includes it, so every SDK error has the trail and rescue Dexpace::Error keeps its phase-1 meaning; Dexpace.attach_suppressed(primary, secondary) skips self (RETRY-34), extends primary with Suppressible when it is not already, appends, and rescues FrozenError into a documented no-op because a helper that raises on a cleanup path is RECOV-12's masked-primary failure; and Dexpace.suppressed(error) reads the trail off anything, returning a frozen empty array when there is none. error-handling/34f54b5e is separately superseded above on the method name; the two findings are independent and both apply. review · docs/work/mvp/phase4/phase4b/2026-09-08-phase4b-recovery-primitives-design.md · high · sha:manual-phase4b-suppressible-module
  • A wrap whose original is Encoding::UndefinedConversionError or Encoding::InvalidByteSequenceError over a secret must NOT carry it as the cause:: the original's message names a character or byte of the input, and #full_message renders the cause chain. Narrows error-handling/866b8ebe ("The cause: passed on rethrow must be the original exception object, not a string, and raw third-party exceptions must be wrapped in a typed domain class before propagating"), whose wrapping half stands and whose "the original exception object" half is wrong for exactly one class of input — a value that is itself a secret. Verified on 2026-09-18 against 3.2.11 and 4.0.6, identically: "hunter日2".encode("ISO-8859-1") raises with the message U+65E5 from UTF-8 to ISO-8859-1, where U+65E5 is 日, a character of the password, and "p\xE4".b.encode("UTF-8") with "\xE4" from ASCII-8BIT to UTF-8, a byte of it; a typed error raised with that object as cause: has a clean #message, #detailed_message and #inspect, but error.full_message(highlight: false) — what Ruby prints for an uncaught exception, and what any cause walk such as Dexpace.each_cause reaches — contains the codepoint on both interpreters. Review round 1 of phase 6c found it (R1-3) after round 0's checks had covered the three clean renderings and not the chain. What the SDK does, per docs/work/mvp/phase6/phase6c/2026-09-09-phase6c-authentication-design.md's as-built row P6-85: Dexpace::Auth::DigestHandler#materialize and Dexpace::Auth::BasicHandler raise their typed failure with cause: nil on both the rescue path and the validity-check path, and carry the information the dropped message held that was NOT a character of the secret — the value's own encoding name — as UnencodableCredentialError#source_encoding and in both messages ("cannot be encoded as ISO-8859-1 from UTF-8"), so the diagnostic loses only the character. error-handling/6d4dbc71's "every wrap-and-rethrow must pass cause:" is honoured literally — the keyword is passed, and its value is nil, which also keeps a caller's in-flight $! from being assigned (../notes/pipeline.md's cause: nil discipline) — and authentication/10d2165f (AUTH-8's "any string/diagnostic representation") is the rule this rests on. What it licenses for later phases: any boundary that transcodes or parses caller-supplied secret material — a serde decoder over a credential-bearing document, a transport adapter re-validating a stamped Authorization value — drops the encoding error from the chain and names the field and the encodings instead. What it does not license: dropping the cause for a non-secret input; phase 1's URL.parse! keeps URI::InvalidURIError as its cause because a URL is not a secret, and the rule stands there as written. review · docs/work/mvp/phase6/phase6c/2026-09-09-phase6c-authentication-checklist.md · high · sha:manual-phase6c-secret-cause