Skip to content

feat: add connectivity error classification to RequestExecutionErrorReason - #1488

Merged
dcalhoun merged 6 commits into
trunkfrom
feat/expose-connectivity-error-classification
Aug 7, 2026
Merged

feat: add connectivity error classification to RequestExecutionErrorReason#1488
dcalhoun merged 6 commits into
trunkfrom
feat/expose-connectivity-error-classification

Conversation

@dcalhoun

@dcalhoun dcalhoun commented Aug 5, 2026

Copy link
Copy Markdown
Member

Description

The request executors map the underlying platform errors onto NonExistentSiteError and DeviceIsOfflineError, but consumers have to match those variants themselves to tell "this site could not be reached" from "this device has no connection". That is fragile — it breaks whenever the enum gains a case — and the knowledge belongs to the library rather than to each consumer.

Raised in GutenbergKit review feedback, tracked by GutenbergKit#578, where the iOS demo app hand-rolls both checks.

Changes

  • RequestExecutionErrorReason::is_site_unreachable() and is_device_offline() as inherent methods, for Rust callers
  • request_execution_error_reason_is_site_unreachable / ..._is_device_offline as #[uniffi::export] free functions, for the bindings
  • Swift: properties on RequestExecutionErrorReason, plus convenience properties on WpApiError and RequestExecutionError. Both conform to a CarriesRequestExecutionErrorReason protocol, so the predicates are written once and each type supplies only the reason accessor.
  • Kotlin: extension properties on RequestExecutionErrorReason
  • Unit tests covering all 12 RequestExecutionErrorReason variants — two positives, ten negatives

Additive only; no behaviour change to existing APIs.

Why these live on the reason rather than on WpApiError

The variants are defined on RequestExecutionErrorReason, and the reason is what consumers actually hold:

  • SwiftWordPress-iOS reaches the reason from two different outer errors (SelfHostedSiteAuthenticator.swift has both a log(error: WpApiError) and a log(error: RequestExecutionError) overload that funnel into the same reason handler). Predicates on the reason serve both; the WpApiError convenience property preserves the error.isCancellationError shape that Extensions/Error.swift already uses.
  • KotlinWpApiException is largely internal plumbing; consumers hold a WpRequestResult. In GutenbergKit's Android demo app, all four files that touch wordpress-rs errors use WpRequestResult and none reference WpApiException. WpRequestResult.RequestExecutionFailed exposes reason directly.

Export shape

Follows application_passwords_url in login::url_discovery — a #[uniffi::export] free function taking a data-carrying uniffi::Enum by reference, with an inherent method behind it. Every #[uniffi::export] impl block in wp_api/src/ targets a uniffi::Object, so an exported impl on an enum would be novel here.

Open questions for review

  1. Predicate placement. Reason vs. WpApiError. The evidence above favours the reason, but I did not find a precedent that settles it either way — happy to move them if you'd rather they sat on the error.
  2. Naming. request_execution_error_reason_is_site_unreachable generates requestExecutionErrorReasonIsSiteUnreachable(reason:), which is a mouthful. The type-prefixed convention is my own; application_passwords_url is unprefixed. Shorter names welcome.
  3. bool vs Option<bool>. Both patterns exist (FindApiRootFailure::is_network_error returns bool; is_application_passwords_disabled returns Option<bool>). I used bool since the question always applies to a reason, but flagging the choice.

Known limitation: the executor mappings disagree

The three executors do not classify the same failure the same way. The doc comments on both predicates now spell this out, but it is worth stating plainly here.

Connection refused — Kotlin is the odd one out:

Condition Swift (SafeRequestExecutor) Rust (ReqwestRequestExecutor) Kotlin (WpRequestExecutor)
Host does not resolve NonExistentSiteError NonExistentSiteError NonExistentSiteError
Connection refused NonExistentSiteError (.cannotConnectToHost) NonExistentSiteError (is_connect()) HttpError (ConnectException)
No route to host NonExistentSiteError NonExistentSiteError HttpError (NoRouteToHostException)

So isSiteUnreachable returns true on Swift and false on Kotlin for the same real-world failure — e.g. a local dev server that is not running. Verified with okhttp 5.4.0: a closed local port throws java.net.ConnectException, which WpRequestExecutor maps to HttpError. HttpError is included as a negative case in the unit tests so the current behaviour is explicit.

Offline detection is platform-only. DeviceIsOfflineError is constructed exclusively by the Swift and Kotlin executors, which consult a NetworkAvailabilityProvider. ReqwestRequestExecutor has no such mapping, so for consumers that build it directly — wp_rs_web, wp_com_e2eis_device_offline() is always false. Worse, an offline failure there fails DNS resolution and is reported as NonExistentSiteError, so is_site_unreachable() returns true instead. The two predicates are effectively inverted on that path.

Only a DNS failure means the same thing everywhere. Callers needing identical behaviour across all three executors should rely on that case alone until the mappings are aligned.

This PR does not change the mappings, because aligning Kotlin with Swift is a behaviour change with a downstream hazard. In WordPress-Android, MediaRSApiRestClient maps NonExistentSiteError to MediaErrorType.NOT_FOUND, and MediaDeleteService responds to NOT_FOUND by deleting the local media record. Today a refused connection yields HttpErrorGENERIC_ERROR, which is non-destructive. Remapping without fixing that first would make an unreachable site look like deleted remote media. The when in question is exhaustive with no else, so the change would compile silently, and there is no test covering that arm.

Suggested sequencing, if the alignment is wanted: fix the WordPress-Android media mapping first, then align ConnectException (and probably NoRouteToHostException) in a follow-up PR here. Giving ReqwestRequestExecutor a way to report offline is a separate question, since it has no NetworkAvailabilityProvider equivalent.

Test plan

Automated:

  • cargo test -p wp_api --lib api_error — 13 tests pass
  • cargo fmt --all -- --check — clean
  • cargo clippy -p wp_api --lib --all-features -- -D warnings — clean
  • swift build --target WordPressAPI — 35/35 files, 0 errors
  • ./gradlew :api:kotlin:compileKotlin :api:kotlin:detekt — BUILD SUCCESSFUL, 0 findings

Manual, via GutenbergKit's iOS demo app pointed at this PR's snapshot branch:

  • wp-env stopped, network up → isSiteUnreachable, correct guidance shown
  • Airplane mode with a saved account → isDeviceOffline, offline editor configuration applied
  • Site host blocked in /etc/hosts, network up → isSiteUnreachable true and isDeviceOffline false on the same error, so the offline fallback correctly does not apply

Changelog

  • I've added an entry to CHANGELOG.md under ## [Unreleased], using the Keep a Changelog categories (Added, Changed, Deprecated, Removed, Fixed, Security). Prefix breaking changes with **BREAKING:**.

@wpmobilebot

wpmobilebot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

XCFramework Build

This PR's XCFramework is available for testing. Add to your Package.swift:

.package(url: "https://github.com/automattic/wordpress-rs", branch: "pr-build/1488")

Built from e5525ed

@dcalhoun
dcalhoun force-pushed the feat/expose-connectivity-error-classification branch from a661e5e to c652fa8 Compare August 6, 2026 16:13
@dcalhoun dcalhoun changed the title feat: add connectivity error classification to WpApiError feat: add connectivity error classification to RequestExecutionErrorReason Aug 6, 2026
dcalhoun added a commit to wordpress-mobile/GutenbergKit that referenced this pull request Aug 6, 2026
Temporarily resolves wordpress-rs from the `pr-build/1488` snapshot branch so
the demo app can build against `WpApiError.isSiteUnreachable` and
`.isDeviceOffline`, which are not in the released 0.6.0.

INTERIM — must be replaced with an exact version before merging. The snapshot
branch is force-pushed on every CI run of Automattic/wordpress-rs#1488 and its
S3 artifact lives under `pr-builds/1488/`, so neither is maintained once that
PR merges. Swap to the release that carries the new API.

Refs #578

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcalhoun
dcalhoun force-pushed the feat/expose-connectivity-error-classification branch from aadfc8d to 867abf4 Compare August 6, 2026 19:52
Comment thread wp_api/src/api_error.rs
Comment on lines +692 to +708
/// # Platform differences
///
/// A refused connection (the host resolves, but nothing is listening) is
/// **not** classified consistently:
///
/// - Swift and the `reqwest` executor map it to `NonExistentSiteError`, so
/// this returns `true`.
/// - Kotlin maps it to `HttpError`, so this returns `false`.
///
/// Only a DNS failure is treated as an unreachable site by every executor.
/// Callers that must behave identically across platforms should rely on that
/// case alone until the mappings are aligned.
///
/// Note also that a malformed site URL never reaches this predicate: it
/// surfaces as [`WpApiError::SiteUrlParsingError`], which carries no
/// `RequestExecutionErrorReason`.
pub fn is_site_unreachable(&self) -> bool {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging the executor divergences documented in the Known limitation section above. I uncovered these while building this out, rather than knowing them going in.

Worth noting they're preexisting: nothing here changes how any executor classifies errors. The divergences exist on trunk today. What this PR adds is a single name spanning all three executors, which is what made them visible.

It remains unclear to me whether that's acceptable for now, whether the mappings should be aligned before this ships, or whether the Swift-only option from the original feedback is the better move. I welcome guidance on the best way to move forward with this.

@jkmassel jkmassel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is as correct as it can be given the inconsistencies you noted. I did some research and opened several issues we can address after this PR lands.

I also opened #1493 with some suggested refinements from my research.

dcalhoun and others added 6 commits August 6, 2026 22:35
…eason

Adds `is_site_unreachable` and `is_device_offline` to
`RequestExecutionErrorReason`, exposed to the bindings as exported free
functions.

The request executors already map the underlying platform errors onto
`NonExistentSiteError` and `DeviceIsOfflineError`, but consumers had to match
those variants themselves. That is fragile — it breaks whenever the enum gains
a case — and the knowledge belongs to the library rather than to each consumer.

The predicates live on the reason rather than on `WpApiError` because that is
where the variants are defined, and because the reason is what consumers
actually hold: `WpRequestResult.RequestExecutionFailed` and
`WpApiException.RequestExecutionFailed` both expose it on Kotlin, and
`RequestExecutionError` carries the same reason as `WpApiError` on Swift.

Export shape follows `application_passwords_url` in `login::url_discovery`: a
`#[uniffi::export]` free function taking a data-carrying `uniffi::Enum` by
reference, with an inherent method behind it for Rust callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wraps the exported classification functions in idiomatic properties:

- Swift: `RequestExecutionErrorReason.isSiteUnreachable` / `.isDeviceOffline`,
  plus convenience properties on `WpApiError` and `RequestExecutionError` that
  delegate through the nested reason. The `WpApiError` shape matches how
  WordPress-iOS consumes `isCancellationError`.
- Kotlin: extension properties on `RequestExecutionErrorReason`, reachable from
  `WpRequestResult.RequestExecutionFailed.reason` — the form WordPress-Android
  already hand-rolls in `ApplicationPasswordValidator` and `PostRsErrorUtils`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doc comments promised more than the predicates deliver, which would lead
consumers to write error-handling branches that never fire.

- `is_site_unreachable` claimed "refused the connection". That is
  platform-divergent: Swift and the `reqwest` executor map a refused connection
  to `NonExistentSiteError`, while Kotlin maps it to `HttpError`. Only a DNS
  failure is classified as an unreachable site by every executor.
- It also claimed "the URL was malformed". A malformed URL surfaces as
  `WpApiError::SiteUrlParsingError`, a sibling of `RequestExecutionFailed`, so
  it never produces a `RequestExecutionErrorReason` at all.
- `is_device_offline` did not mention that `DeviceIsOfflineError` is only ever
  constructed by the Swift and Kotlin executors. Under the `reqwest` executor
  it is always `false`, and an offline failure is reported as
  `NonExistentSiteError` via the DNS resolution failure — inverting both
  predicates.

Narrows each doc to what actually holds and documents the divergences, on the
Rust methods and in the Swift and Kotlin wrappers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The negative-case list omitted `MisconfiguredHttpAuthenticationError`, so the
suite did not assert that both predicates return `false` for it.

All 12 variants are now covered: two by the positive tests, ten by the negative
case list. Since the predicates use `matches!` rather than an exhaustive match,
a new variant would silently return `false` from both without a compile error —
this list is the only thing that would catch it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`WpApiError` and `RequestExecutionError` each carried a verbatim copy of
`executionErrorReason` plus both predicates. They share the same
`RequestExecutionFailed` payload, so the copies would drift if that payload
ever changes.

Introduces `CarriesRequestExecutionErrorReason`, which both conform to. The
predicates are written once in a protocol extension; each type supplies only
the reason accessor.

No change to the public surface: `error.isSiteUnreachable` and
`.isDeviceOffline` still resolve on both types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jkmassel
jkmassel force-pushed the feat/expose-connectivity-error-classification branch from 867abf4 to e5525ed Compare August 7, 2026 04:37
@dcalhoun
dcalhoun merged commit c9b98f4 into trunk Aug 7, 2026
36 checks passed
@dcalhoun
dcalhoun deleted the feat/expose-connectivity-error-classification branch August 7, 2026 12:33
dcalhoun added a commit to wordpress-mobile/GutenbergKit that referenced this pull request Aug 7, 2026
Temporarily resolves wordpress-rs from the `pr-build/1488` snapshot branch so
the demo app can build against `WpApiError.isSiteUnreachable` and
`.isDeviceOffline`, which are not in the released 0.6.0.

INTERIM — must be replaced with an exact version before merging. The snapshot
branch is force-pushed on every CI run of Automattic/wordpress-rs#1488 and its
S3 artifact lives under `pr-builds/1488/`, so neither is maintained once that
PR merges. Swap to the release that carries the new API.

Refs #578

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dcalhoun added a commit to wordpress-mobile/GutenbergKit that referenced this pull request Aug 7, 2026
The `pr-build/1488` snapshot branch was deleted once that PR merged, taking
its S3 artifact under `pr-builds/1488/` with it, so the previous pin no
longer resolves.

Repoints at `trunk-build`, the published mirror of trunk. Trunk itself
cannot be consumed as a remote SPM dependency: its `Package.swift` sets
`libwordpressFFIVersion = .local`, which expects
`target/libwordpressFFI.xcframework` to be built locally by Cargo and is
absent from a fresh checkout. `trunk-build` carries the same Swift sources
but declares `.release`, fetching the prebuilt xcframework from the CDN.

Pinned by revision rather than branch because `trunk-build` is force-pushed
on every trunk CI run. `d70c99e1` is the build of trunk `c2f8a25f`, which
carries `WpApiError.isSiteUnreachable` and `.isDeviceOffline`.

INTERIM — must be replaced with an exact version before merging. No tagged
release carries these helpers yet; the latest, `alpha-20260313.1`, predates
Automattic/wordpress-rs#1488. Swap to the release that carries the new API.

Refs #578

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jkmassel added a commit that referenced this pull request Aug 7, 2026
A refused connection (the host resolves, but nothing is listening) was
classified as NonExistentSiteError on Swift but not on Kotlin or reqwest, so
the isSiteUnreachable predicate from #1488 returned a different answer per
platform for the same outage.

Move Swift's .cannotConnectToHost out of the non-existent-site set, leaving
NonExistentSiteError to mean a DNS-resolution failure. The follow-up commit
gives refused/unreachable connections a dedicated ConnectionError reason
across all three executors.

Refs #1495.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants