Skip to content

Classify an error code by the failure's origin - #135

Merged
willkg merged 8 commits into
mainfrom
error-code-classification
Sep 7, 2026
Merged

Classify an error code by the failure's origin#135
willkg merged 8 commits into
mainfrom
error-code-classification

Conversation

@willkg

@willkg willkg commented Sep 7, 2026

Copy link
Copy Markdown
Member

Fixes #133.

The bug, in both directions

create's preflight 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". The worst case is the one RejectedCredential exists for: a revoked token answers every v2 route with a 404 naming nothing, GetPageOrNil deliberately does not read that one as "absent", and CodeFor asks RejectedCredential before its status switch precisely so it reports AUTH — which create threw away. The same token already reported AUTH from phase 3, so one credential produced two codes depending on which phase noticed. cmd/fix got the status half right via its own locateCode, 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.planAttachments calls fileChecksum(att.Path) — a local os.Open — so a SyncAttachments/PlanAttachments/ForceUploadAttachments failure classified through bare CodeFor reported NETWORK for a file on disk. Four sites did that, including attachment-upload, whose entire input is local files and which already tells IO from VALIDATION upstream in localAttachmentsCode before losing the distinction one call later. create's publishOne comment names this exact condition as one of S7's residuals.

What this does

CodeFor alone cannot fix either direction — it answers NETWORK for any non-HTTPError, so routing everything through it would report no title given as a transport problem. The issue suggested lifting fix's locateCode into internal/jsonout, but a bare type check on *HTTPError only fixes the status half: doJSON builds an HTTPError only once it has a status, so a dial failure, a TLS error, or an undecodable response still took the local fallback — and NETWORK vs VALIDATION is the distinction a consumer actually branches on to decide whether retrying is worth anything.

So internal/client types 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. *HTTPError once there is a status, an unexported requestError when there is none, and FromRequest answers for both. jsonout.CodeOr(err, fallback) classifies by CodeFor when FromRequest holds and takes the fallback otherwise — a parameter rather than a hardcoded VALIDATION, so the call site states which local meaning it means.

The alternative was per-call-site markers in create and fix (the convertFailure/badInput idiom 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 reports VALIDATION, 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 in internal/attachfile rather than in two commands.

Deliberately not a claim that every error from internal/client 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 lie in a new place. The wrapper carries no message of its own, so Error() is the inner text verbatim: no human output moves and no existing string assertion changes. The only observable difference is the code field.

_plans/035_error-code-classification.md has the full decision record, including why the new type is unexported (FromRequest is the whole new surface, and unexported→exported is the reversible direction) and why a malformed 200 stays NETWORK.

Scope

The mirror sites are fixed here rather than deferred. _plans/034 deferred #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 --json code change on failures #133 does not mention: NETWORKIO for an unreadable or missing local asset.

No schema change: $defs/code is 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_id that resolves to nothing is a local error classified NOT_FOUND by update but VALIDATION by create/fix, which is a judgment about what that condition means rather than this rule; and an error-code-names-the-cause guarantee, which would have to land Partial since it rests on 118 code-assignment sites being individually right.

Verification

make check clean. 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 invariant FromRequest documents. Harmless today, since find/search still use bare CodeFor, but it is #133's shape waiting for the first search failure routed through CodeOr. The reviews also caught that neither create's nor fix's test covered a request failure with no status (added) and that a new comment described DownloadAttachment as streaming to the file when it buffers and writes once (corrected).

What stays unpinned, deliberately: three of the four mirror sites. create and update cannot reach fileChecksum's error through the converter — a missing asset is reported IMAGE BROKEN and never becomes an attachment — so provoking it needs a chmod 000 that behaves differently as root, or a client hook existing only for a test. attachfile.Write is pinnable only through the no-download-link path, which would cement an imprecision the code documents as one. The rule itself is pinned by the jsonout table on a real fs.PathError, and attachment-upload exercises the local direction end to end through the command's own planCode.

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
@willkg
willkg merged commit 0e92fbb into main Sep 7, 2026
1 check passed
@willkg
willkg deleted the error-code-classification branch September 7, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

create: a preflight HTTP failure reports VALIDATION

1 participant