Skip to content

refactor(kit): keep the response body on an HTTP failure, opt-in - #106

Merged
Adron merged 2 commits into
devfrom
refactor/apierror-response-body
Sep 17, 2026
Merged

Adron merged 2 commits into
devfrom
refactor/apierror-response-body

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Closes #103.

APIError keeps only the decoded {error} string. Everything else in the body is discarded at APIClient.swift:234 before any caller sees it — right for almost every failure, wrong for the few where the server answers a question rather than reporting a malfunction:

Both callers already exist, and both are written around the gap with a comment explaining why the better behaviour is absent.

Why not just add an associated value

420 sites across 78 files construct or pattern-match those cases, including every test. That is an enormous mechanical change — every line of it a chance to get a case wrong — in service of two call sites. It is also exactly why both PRs flagged this and moved on rather than doing it in passing.

The design: opt-in

sendCapturingFailure(_:) throws an APIFailure carrying the body. Every other entry point keeps throwing plain APIError, and not one of the 420 sites changes.

APIFailure is deliberately not an APIError, so a caller opts in by choosing the richer send rather than by accident. It forwards localizedDescription and description, so nothing regresses if one reaches generic error-display code.

details(as:) returns nil rather than throwing on a mismatch. A caller asking for details is asking an optional question, and a decode failure there must degrade to today's behaviour — a message with no details — instead of masking the HTTP failure actually being handled.

A protocol-extension default wraps with body: nil, so every existing stub and fake conforms unchanged and an opted-in caller against a stub simply gets nil details. That is honest: a transport failure has no body either.

The trap this surfaced

The transport now raises APIFailure internally so one code path serves both entry points, with send/sendVoid/sendRaw/sendWithRateLimitInfo flattening it back.

Doing that silently disabled the 401 session-retry and the entire retry policy — performWithSafetyNet and performWithRetry both catch let error as APIError, which stopped matching.

The existing AuthTransportTests caught it, which is the system working. Both now match on underlyingError and rethrow the APIFailure whole — flattening there would drop the body before sendCapturingFailure ever saw it, which is the entire point of the type.

Worth knowing in review: that is the one genuinely risky part of this change, and it is covered by a test that predates it.

A decode failure is not an HTTP failure

A 2xx whose body does not match stays a plain APIError.decoding. It is a client-side problem with no server body to offer, and dressing it as an APIFailure carrying nothing would be a lie about where the fault is. There is a test.

Verification

  • xcodebuild build → ** BUILD SUCCEEDED **
  • xcodebuild test (App) → Executed 968 tests, with 0 failures · ** TEST SUCCEEDED **
  • swift test InterlinedKit --skip ContractTests → Executed 483 tests, with 0 failures (was 477)
  • swift test InterlinedDomain → Executed 1012 tests, with 0 failures
  • swift test InterlinedPersistence → Executed 140 tests, with 0 failures
  • Decision 0003 (anchored) → zero hits

New tests (6): details decode · message unchanged from APIError · a normal send still throws a plain APIError (the property that must not break) · a non-matching body degrades to nil · no body at all · an undecodable 2xx stays .decoding.

Wiring the two consumers

Deliberately not in this PR. ListsService.updateSchema lives on #87 and AppSettingsService on #102; wiring them here would mean either duplicating those branches' work or creating a three-way stack.

Once #87, #102 and this have merged, each consumer is a one-line change — swap api.send for api.sendCapturingFailure and read details(as:) — and ListSchemaConflictDTO already exists for the first.

Branches from #99 (Xcode 27 Document fix), without which the App target does not build.

🤖 Generated with Claude Code

Adron and others added 2 commits September 16, 2026 12:45
…Xcode 27

`dev` stopped building when Xcode was updated on this machine mid-session. The
macOS 27 SDK adds a `Document` protocol to SwiftUI —

    public protocol Document: ReadableDocument, WritableDocument

— which collides with the domain's `Document` struct in any file importing both.
That is every documents-feature view, and the same unchanged source went from
compiling to eleven `'Document' is ambiguous for type lookup` errors across eight
files.

Only the SwiftUI-importing files are affected, which is what makes the diagnosis
unambiguous: the view models import Foundation, Observation and InterlinedDomain
but not SwiftUI, and they compile untouched.

The fix is to qualify the type at the use sites. Three alternatives were
considered and rejected. Renaming the domain model is the tail wagging the dog —
`Document` is the right name, and it is correct across Kit, Domain, Persistence
and their tests. A module-level typealias would shorten the use sites at the cost
of giving one concept two names, so the next reader has to learn they are the
same thing. Dropping `import SwiftUI` is not available; these are views.

Every edit is a type position. No user-facing string, accessibility label or
other identifier containing the word Document is touched — the diff is eleven
lines, each one a `Document` that the compiler itself pointed at.

Worth knowing rather than fixing: this is a standing hazard. Any domain type
sharing a name with a SwiftUI symbol is one SDK update away from the same break,
and the diagnosis is written down at the top of DocumentsListView so the next
occurrence takes minutes.

Refs #98

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
APIError keeps only the decoded `{error}` string; everything else in the body is
discarded before any caller sees it. That is right for almost every failure,
where a sentence is all the UI needs — and wrong for the few where the server
answers a question rather than reporting a malfunction. The list-schema
destructive-change guard replies 400 with a `propertiesWithData` array naming
the columns that still hold data, and the app-settings family is compare-and-set,
replying 409 with the current document attached. Both callers exist and both are
written around the gap with a comment saying so.

The obvious fix — a second associated value on the error cases — is not viable.
420 sites across 78 files construct or pattern-match those cases, including
every test. That is an enormous mechanical change, every line of it a chance to
get a case wrong, in service of two call sites.

So this is opt-in. `sendCapturingFailure(_:)` throws an `APIFailure` carrying the
body; every other entry point keeps throwing plain `APIError` and not one of the
420 sites changes. `APIFailure` is deliberately not an APIError, so a caller
opts in by choosing the richer send rather than by accident — and it forwards
`localizedDescription` so nothing regresses if one reaches generic error display.

`details(as:)` returns nil rather than throwing on a mismatch. A caller asking
for details is asking an optional question, and a decode failure there must
degrade to today's behaviour instead of masking the HTTP failure being handled.

The transport raises the richer error internally so one code path serves both
entry points, and `send`/`sendVoid`/`sendRaw`/`sendWithRateLimitInfo` flatten it
back. Writing that revealed a real trap: `performWithSafetyNet` and
`performWithRetry` both catch `APIError`, so raising `APIFailure` silently
disabled the 401 session-retry and the whole retry policy. The existing
AuthTransport test caught it. Both now match on `underlyingError` and rethrow the
`APIFailure` whole — flattening there would drop the body before
`sendCapturingFailure` ever saw it, which is the entire point of the type.

A decode failure on a 2xx stays a plain `APIError.decoding`: it is a client-side
problem with no server body to offer, and dressing it as an `APIFailure`
carrying nothing would be a lie about where the fault is.

Refs #103

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant