Say a refused update is a refusal, in every adapter - #98
Merged
Merged
Conversation
Since #97 a split creates its new series first and takes it back only when the truncate certainly wrote nothing (`writeNeverLanded`: a refusal token or a refusing code). Several certain refusals arrived as `protocol`: EWS SOAP error answers to the one `UpdateItem`, Google and Graph HTTP 400 and 429, and CalDAV's own checks before the PUT. Both series then stayed with the warning that the series may show twice, although it certainly did (decision 147). - cal-core: `WriteRefusal::refused_status` names the statuses with which a server turned a write down whole (400, 413, 415, 422, 429, 507), and the new `UnsafeToWrite` refusal says Aperio did not write because it could not do so safely. `parse` now knows every refusal (`occurrence-not-writable` was missing). - Each adapter's `update_event` maps such an answer to `Forbidden("server-refused: …")`; EWS also every SOAP error answer to the update except internal and timeout errors; CalDAV's own checks say `unsafe-to-write` with a log token. `forbidden` reaches JS on the phone. - Graph: a DELETE that finds nothing after a successful `/cancel` is done: the cancel moved the event to Deleted Items, which gives it a new id. - Frontend: `unsafe-to-write` has its sentence and reason in EN and DE. - DESIGN.md and TODO.md follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- EWS: the denylist ("every SOAP error but internal and timeout") took in
codes a meeting update raises after it saved the item and while it sends
it (send-as denied, quotas, message size, the store), `Unknown` and
generic faults. `REFUSED_UPDATE_CODES` is now an allowlist of the codes
Exchange raises while it checks or throttles the request; any other code
stays unsure. The code is also read from a SOAP fault sent with HTTP 500,
the usual shape of `ErrorServerBusy`, without its namespace prefix.
- CalDAV: any failure of a replayed PUT whose first attempt may have
landed is unsure, not only a 412: a 429 or 507 may be about the first
attempt.
- Google and Graph: a token endpoint that refuses the refresh (400/401,
`invalid_grant`) is a sign-in failure, reported as a 401, not the
calendar server refusing the change. A refused update carries the
server's own reason after the status.
- CalDAV: `to_core_error`'s doc comment is back on it.
- TODO.md follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- EWS: `ErrorIrresolvableConflict` and `ErrorStaleObject` are off the refusal list. The update is sent with `AlwaysOverwrite`, so no ChangeKey is checked before the save, and a conflict code comes from somewhere else; it stays unsure. - CalDAV: a replayed PUT answered with a failure keeps its answer: the status goes into the error and status and body into the log, where the replay rule had dropped both. - Google and Graph: the error keeps only the first 300 characters of an answer, so a realistic envelope arrived as broken JSON and its reason was dropped. `server_reason` now reads the first "message" string of a cut answer as far as it goes. - host-core: `is_auth_shaped`'s doc and test name the shape Google and Graph now report for a refused refresh, beside the one other OAuth adapters still send. - TODO.md names the two tokens that travel as `forbidden`, and where the replay and refresh failures travel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Third PR of the arc "exceptions when splitting a series" (decision 134), decision 147. It follows #97.
Why
Since #97, a split creates the new series first and then truncates the old one. When the truncate fails, the new series is deleted again only if the truncate certainly wrote nothing:
writeNeverLandedchecks for a refusal token or a refusing code. Several certain refusals did not arrive that way:UpdateItem, such asErrorServerBusy, arrived asprotocol;protocol;protocol.In each case both series stayed, with the warning that the series may show twice, although it certainly did: nothing had been cut. And Graph's DELETE after a successful
/cancelmay find nothing, because the cancel moves the event to Deleted Items and a move gives an Outlook item a new id. So undoing the new series of a meeting reported a failure, and the series "may show twice", while it had been cancelled and removed.The change
One rule in cal-core.
WriteRefusal::refused_status(status)covers the statuses with which a server turned a write down whole: 400, 413, 415, 422, 429, 507. The statuses the adapters already name (401, 403, 404, 409, 412) keep their errors. A 5xx says nothing either way and stays unsure.Every adapter's
update_eventreports such a refusal as one. Each adapter maps it toForbidden("server-refused: …"). Theforbiddencode reaches JS on the phone too, where most other codes do not (TODO B9).to_update_errorapplies the status rule.UpdateItem, except the server's internal and timeout errors, which may come after part of the work. Sign-in and not-found faults keep their errors.unsafe-to-write, a newWriteRefusalmeaning "Aperio cannot change this event safely". The detail (unreadable-blocks,no-such-event,mixed-organizers) is a token for the log; the prose goes to awarn!.Graph: a DELETE that finds nothing after a successful
/cancelis done. Without a cancel, a 404 is still "not found". Thecancel_eventdoc said the event stays on the calendar until deleted; corrected.Frontend:
unsafe-to-writehas its sentence and its reason in EN and DE (shared/eventWriteError.ts).WriteRefusaltype now lists it, so both apps' tables must name it.In passing:
WriteRefusal::parsedid not knowoccurrence-not-writable. It is unused outside tests, and now knows every refusal.Not here: creating and deleting still report such refusals as
protocol. Only the truncate decides anything in a split, and the messages elsewhere are unchanged.Docs: DESIGN.md lists the tokens and what a token means for
writeNeverLanded. In TODO.md the 147 entry is done, and a sentence my earlier edit had put in the wrong entry is back in place.Checks
cargo test --workspace --all-features: 2861 passed.-D warningsclean.cal-coreand each of the four adapters checked alone: clean.cargo xtask ts-types --checkcurrent (WriteRefusal.ts).tscandeslintclean;TZ=UTC;check:bindingspasses.server-refused: HTTP …; one answered 503 stays a protocol error. Both run through the adapter against a mock server./cancelis done;unsafe-to-write: mixed-organizers, with no PUT sent;unsafe-to-writereads as its sentence, and as nothing written;server-refused: HTTP 429counts as nothing written.update_eventwiring. Their mapping functions are tested, but the onemap_errin each adapter's trait method has no harness for a full update.Review round
One adversarial round, with two lenses: could a write that landed now read as a refusal, and what does the new error touch elsewhere. Every finding was verified: 9 confirmed, several of them found by both lenses, and 4 refuted. Fixed here:
Unknown, and generic faults can all come after the save, and reading them as refusals deleted the new series around a truncate that had landed.REFUSED_UPDATE_CODESis now an allowlist of the codes Exchange raises while it checks or throttles the request:ErrorServerBusy, the invalid-request and schema codes, the invalid-property codes, an invalid recurrence, and a malformed id or change key. Every other code stays unsure.ErrorServerBusyusually comes as a SOAP fault with HTTP 500, which the client returns as an HTTP error before it reads the fault. The update's mapping now reads the fault from a 500 answer and strips the namespace prefix (a:ErrorServerBusy). Any other 500 stays unsure.invalid_grant) read as "the calendar server refused this change (HTTP 400)".refresh_access_tokennow reports a 400 or 401 from the token endpoint as a 401, so it is the sign-in failure it is. That also matches what the Google module doc already claimed. A refused update also carries the server's own reason ({"error":{"message":…}}), trimmed, after the status.to_core_error's doc comment had ended up on the new function; moved back.unsafe-to-writesentence;Checks for the round:
-D warningsclean; each of the four adapters checked alone: clean.Unknownand generic codes stay unsure; aa:ErrorServerBusyfault sent with HTTP 500 is a refusal, while a 500 internal-server-error fault and a non-SOAP 500 page stay unsure.Second review (of the review round)
Two lenses (EWS and CalDAV; OAuth): 5 confirmed, all low; 5 refuted. All fixed here:
ErrorIrresolvableConflictandErrorStaleObjectare off the refusal list. The update is sent withAlwaysOverwrite, so no ChangeKey is checked before the save, and a conflict code comes from somewhere else. It stays unsure.server_reasonnow reads the first"message"string of a cut answer as far as it goes.is_auth_shaped's doc and test name the shape Google and Graph now report for a refused refresh, beside the one other OAuth adapters still send.forbidden, and says where the replay and refresh failures travel.Checks for the round:
-D warningsclean.Phone
A new
.sois needed for the adapters' new errors. With an older one the phone behaves as before #97's refusal check: both series stay, with the warning.🤖 Generated with Claude Code