Classify an error code by the failure's origin - #135
Merged
Conversation
Fixes #133 by making a --json failure's code distinguish a server failure from a local one, in both directions: create's preflight stops reporting VALIDATION for an HTTP failure, and the four attachment-path sites stop reporting NETWORK for a local file that cannot be read.
doJSON built an *HTTPError only once it had a status, so a transport failure, a request that would not build, and a response body that would not decode all came back as plain errors indistinguishable from a caller's own "no title given". A command classifying a failure for --json could therefore tell a 403 from a bad file, but not a dropped connection from one. requestError tags those three, so the package now returns exactly two error types on the request path and answers FromRequest about both. It carries no message of its own -- Error() is the inner text verbatim -- so no human output and no message-asserting test changes; the only observable difference is what a caller may now conclude. Deliberately not a claim that every error from this package is typed: DownloadAttachment writing to the caller's writer, uploadAttachment opening the caller's file, and Resolve reading the environment are local failures, and tagging them would misreport an unreadable file as a network problem -- the same defect in a new place. Refs #133
A failure site that mixes local and server errors -- create's preflight, fix's page location, every attachment path -- has had only two wrong options. CodeFor answers NETWORK for anything that is not an *HTTPError, so "no title given" becomes a transport problem; a constant reports a rejected credential as a defect in a file that is fine. CodeOr asks client.FromRequest first, so a request failure classifies by status (and by RejectedCredential before it) while a local one takes the caller's fallback. The fallback is a parameter so the call site states which local meaning it means: VALIDATION where the file is wrong, IO where it could not be read. Refs #133
…ATION Phase 1 makes four kinds of server call -- checkPageID, ResolveSpaceID, checkParentInSpace, checkTitleFree -- and newFailure stamped every phase-1 error VALIDATION, the code that means "there is something wrong with your file". So a 403, a 500, or a rejected credential from any of them blamed the file. The rejected credential is the case that matters. It arrives as a 404 on every v2 route, GetPageOrNil does not read that one as "absent", and CodeFor asks RejectedCredential before its status switch precisely so it reports AUTH -- which create then threw away. The same token already reported AUTH from phase 3, so one credential produced two different codes depending on which phase noticed. newFailure now defaults through jsonout.CodeOr. Every local phase-1 error is not a client error, so it still takes VALIDATION, and the convertFailure check stays after it so CONVERT still wins. Fixes #133
locateCode was the rule create needed, so it moved to internal/jsonout rather than being copied. fix loses nothing and gains the transport case: its own type check reported VALIDATION for a dial failure, because doJSON builds an *HTTPError only once there is a status. Its unit test is replaced by one running through processFile, so what is pinned is the wiring rather than a helper that no longer lives here -- including the assertion #133 is actually about, that the credential create's preflight now reports AUTH reports AUTH here too. Refs #133
#133 inverted, at four sites. client.planAttachments checksums every local file, so SyncAttachments/PlanAttachments/ForceUploadAttachments fail with an os.Open error when an asset cannot be read -- and bare CodeFor answers NETWORK for anything without an HTTP status, so --json blamed the network for a file on disk. create's publishOne names this exact condition as one of S7's residuals ("an image that Lstat'd fine in preflight can still be unreadable now"). attachment-upload is the worst of the four: its whole input is local files, and it already separates IO from VALIDATION upstream in localAttachmentsCode before losing the distinction one call later. attachfile.Write is the download direction, where DownloadAttachment writes to the destination file as it goes. All four now pass IO as the fallback, so a server failure on the same call still classifies by its status -- which is what the new attachment-upload test pins from both sides. Refs #133
CLAUDE.md's internal/client bullet: the package returns an *HTTPError once there is a status and an unexported requestError when there is none, FromRequest answers for both, and the rule is scoped to the request -- the writer, the file, and the environment stay untyped on purpose. The plan is amended where implementation diverged from it: the pagination helpers turned out to have no wrap site (resolveNext swallows its own url.Parse failure), attachment-upload can test the local direction after all because no converter stands in the way, and the page_id-resolves-to- nothing split that fix's test surfaced is recorded as out of scope. Refs #133
…tusless case From two independent reviews of this branch, which found the same gap. searchCQLBounded's page-count bail returned an untagged error, so the one request-path failure that is not a response sat outside the invariant FromRequest's doc comment states. It fires because the server kept handing back a next link, which is a request failure by that rule. Harmless today -- find and search still classify with bare CodeFor -- but the moment a search failure goes through CodeOr, runaway pagination would report VALIDATION, which is #133 in a new place. Neither create's nor fix's test covered a request failure carrying no status, which is the only thing CodeOr does that the type check it replaced did not. Both now have a row for an undecodable 200, verified to report VALIDATION under the old rule. An undecodable response stands in for a dropped connection because a transport failure on a GET spends the full retry budget in real time: only internal/client can stub the backoff. Also corrects the mechanism in attachfile.Write's new comment -- DownloadAttachment buffers the response and writes it once, rather than streaming to the file -- and narrows the plan's claim about which pagination helpers have no wrap site. Refs #133
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #133.
The bug, in both directions
create's preflight makes four kinds of server call —checkPageID,ResolveSpaceID,checkParentInSpace,checkTitleFree— andnewFailurestamped every phase-1 errorVALIDATION, the code that means "there is something wrong with your file". The worst case is the oneRejectedCredentialexists for: a revoked token answers every v2 route with a 404 naming nothing,GetPageOrNildeliberately does not read that one as "absent", andCodeForasksRejectedCredentialbefore its status switch precisely so it reportsAUTH— whichcreatethrew away. The same token already reportedAUTHfrom phase 3, so one credential produced two codes depending on which phase noticed.cmd/fixgot the status half right via its ownlocateCode, which is what made this worth fixing rather than tolerating.The same defect runs the other way on the attachment paths, and #127 walked past it.
client.planAttachmentscallsfileChecksum(att.Path)— a localos.Open— so aSyncAttachments/PlanAttachments/ForceUploadAttachmentsfailure classified through bareCodeForreportedNETWORKfor a file on disk. Four sites did that, includingattachment-upload, whose entire input is local files and which already tellsIOfromVALIDATIONupstream inlocalAttachmentsCodebefore losing the distinction one call later.create'spublishOnecomment names this exact condition as one of S7's residuals.What this does
CodeForalone cannot fix either direction — it answersNETWORKfor any non-HTTPError, so routing everything through it would reportno title givenas a transport problem. The issue suggested liftingfix'slocateCodeintointernal/jsonout, but a bare type check on*HTTPErroronly fixes the status half:doJSONbuilds anHTTPErroronly once it has a status, so a dial failure, a TLS error, or an undecodable response still took the local fallback — andNETWORKvsVALIDATIONis the distinction a consumer actually branches on to decide whether retrying is worth anything.So
internal/clienttypes its own request-path errors instead, and callers ask a predicate rather than marking call sites. The rule: an error a client method returns because the request failed is typed; an error that came from the caller's own data is not.*HTTPErroronce there is a status, an unexportedrequestErrorwhen there is none, andFromRequestanswers for both.jsonout.CodeOr(err, fallback)classifies byCodeForwhenFromRequestholds and takes the fallback otherwise — a parameter rather than a hardcodedVALIDATION, so the call site states which local meaning it means.The alternative was per-call-site markers in
createandfix(theconvertFailure/badInputidiom already in the tree). Rejected because the obligation would land on every future call site and fail silently when forgotten — a new client call in preflight without the wrapper reportsVALIDATION, which is this bug reintroduced. Typing at the source puts the guarantee in one function a test can hold, the same reasoning that keeps the traversal clamp ininternal/attachfilerather than in two commands.Deliberately not a claim that every error from
internal/clientis typed:DownloadAttachmentwriting to the caller's writer,uploadAttachmentopening the caller's file, andResolvereading the environment are local failures, and tagging them would misreport an unreadable file as a network problem — the same lie in a new place. The wrapper carries no message of its own, soError()is the inner text verbatim: no human output moves and no existing string assertion changes. The only observable difference is thecodefield._plans/035_error-code-classification.mdhas the full decision record, including why the new type is unexported (FromRequestis the whole new surface, and unexported→exported is the reversible direction) and why a malformed 200 staysNETWORK.Scope
The mirror sites are fixed here rather than deferred.
_plans/034deferred #133 because it changed codes on failures that issue was not about, when there was no shared rule to appeal to — the rule is now the thing being added, so applying it everywhere it belongs is the change. The accepted cost is a--jsoncode change on failures #133 does not mention:NETWORK→IOfor an unreadable or missing local asset.No schema change:
$defs/codeis one global enum, all eight codes valid on every result shape. No README change: it lists the eight values and never claims which one a given failure carries. Exit codes do not move — the README scopes exit 2 to credential resolution, not the server rejecting one.Two things found during the work and left out, both recorded in the plan's Out of scope: a
page_idthat resolves to nothing is a local error classifiedNOT_FOUNDbyupdatebutVALIDATIONbycreate/fix, which is a judgment about what that condition means rather than this rule; and anerror-code-names-the-causeguarantee, which would have to land Partial since it rests on 118 code-assignment sites being individually right.Verification
make checkclean. Both #133 tests were confirmed to fail against the old code first (withid.md code = "VALIDATION", want AUTH), and the statusless rows were mutation-tested against the type check they replace.Two independent code reviews ran on the branch and converged on one real gap, now fixed in the last commit:
searchCQLBounded's page-count bail returned an untagged error — the one request-path failure that is not a response — which sat outside the invariantFromRequestdocuments. Harmless today, sincefind/searchstill use bareCodeFor, but it is #133's shape waiting for the first search failure routed throughCodeOr. The reviews also caught that neithercreate's norfix's test covered a request failure with no status (added) and that a new comment describedDownloadAttachmentas streaming to the file when it buffers and writes once (corrected).What stays unpinned, deliberately: three of the four mirror sites.
createandupdatecannot reachfileChecksum's error through the converter — a missing asset is reportedIMAGE BROKENand never becomes an attachment — so provoking it needs achmod 000that behaves differently as root, or a client hook existing only for a test.attachfile.Writeis pinnable only through the no-download-link path, which would cement an imprecision the code documents as one. The rule itself is pinned by thejsonouttable on a realfs.PathError, andattachment-uploadexercises the local direction end to end through the command's ownplanCode.