Skip to content

fix(cloudsync): tell the failure modes apart, retry the ones worth retrying - #10

Open
vishk23 wants to merge 1 commit into
fix/cloudsync-gate-leakfrom
fix/cloudsync-error-taxonomy
Open

vishk23 wants to merge 1 commit into
fix/cloudsync-gate-leakfrom
fix/cloudsync-error-taxonomy

Conversation

@vishk23

@vishk23 vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Stacked on #7 (fix/cloudsync-gate-leak) — based on its head, so this PR shows only its own commit. GitHub will retarget it to main when #7 merges.

The part of the ten days this fixes

#7 explains why a phone stopped syncing at all: the process-wide gate was stuck held, so every entry point bailed instantly. That is the whole story of why nothing ran. This PR is about the other half — what happened on the days something did run, and why nobody could tell.

The server was only broken for part of that window, and it was loudly specific about how. POST /ingest answers 507 insufficient_space when the volume is full, and its handler says in a comment exactly why the status matters: so the phone reads it as "server has no room, keep the backup and retry later" rather than "this upload is malformed, discard it". It answers 400 corrupt_sqlite for the opposite case. POST /deepbuf answers 503 storage_not_configured when object storage was never set up — an optional feature, not a fault.

The client threw all of that away:

enum CloudSyncError { case badResponse(Int, String); case network(String); case decode }
// "The cloud sync server returned an error (\(code))."

So every one of those became the same sentence with a different number in it. On the Data Sources card, side by side, sat:

The cloud sync server returned an error (507)
Deep buffers: The cloud sync server returned an error (503)

One of those was a real outage. The other was a feature that had never been switched on. Nothing in the app distinguished them, so one broken thing read as two, and the message that mattered was buried next to one that never did.

And there was no retry and no backoff anywhere. A single 500 from a restarting server ended the sync. The next attempt was not seconds later — it was whenever the 20h autoSyncIfDue or 4h backgroundSyncIfDue window next opened, and autoSyncIfDue runs once per launch on a process that stays resident. One unlucky second cost a day.

What changed

1. CloudSyncError becomes a taxonomy (Strand/CloudSync/CloudSyncError.swift). One place — CloudSyncClient.throwIfNotOK — turns (status, body) into exactly one case, driven by the server's own machine-readable error code, so no endpoint can grow a private interpretation of a status and no new endpoint can re-collapse them.

Server says Case Retry?
507 insufficient_space / storage_unavailable serverOutOfSpace yes
500/502/504, and any other 503 serverFault yes
transport / timeout network yes
400 corrupt_sqlite, bad_zip, 413 too_large, 404 rejected(status:code:detail:) no
401/403 unauthorized no
503 storage_not_configured featureNotConfigured not an error at all
cancelled (CancellationError or URLError.cancelled) cancelled no
2xx that won't parse decode no

2. Bounded retry with exponential backoff + a jitter band (CloudSyncRetry.swift), consulting isRetryable and nothing else.

The budget is deliberately small: ~1s then ~3s, three attempts, ~4s worst case. The target cadence is a sync every time the phone is unlocked, so giving up early now costs one more unlock rather than one more day — which makes a long in-sync ladder the wrong trade. A retry here rides out a blip a human wouldn't notice; it does not try to out-wait an outage. CloudSyncClient.syncSession's 1h request ceiling and CloudSyncGate.staleHoldS's 2h reclaim both sit far above it, so the ladder can never itself be what wedges a sync.

/ingest gets its own tighter budget — one retry, not two. Resending a 100-300MB .noopbak is minutes of cellular data. It is still worth one, because the most common /ingest refusal (507) is answered from the declared Content-Length before the server accepts a byte of the body, so that attempt cost nothing to make and nothing to repeat.

Jitter is a full band around each delay rather than a bare doubling: every phone an outage knocked offline otherwise retries on the same schedule and returns as a thundering herd.

3. The deep-buffer 503 is a skip, not a failure. DeepBufferDrainSummary gains skippedNotConfigured, deliberately not error — error is what becomes a user-visible · Deep buffers: … suffix. The watermark stays exactly where it was, so the moment a bucket is configured the same bytes ship; nothing is written off. The lane is then not re-probed for 6h, because the 503 only arrives after the chunk body is sent, and re-uploading megabytes on every unlock to be told "no" is not a no-op. Six hours, not sticky, so provisioning heals on its own the same day. (Tigris has since been provisioned, so this path should now succeed — the handling still has to be right, and a 500 deepbuf_failed still reports as a real failure.)

4. cloudsync.lastStatus says something a human can act on. Every message now ends with what happens next:

  • Failed — The server has no room for the backup right now (insufficient_space). Nothing was lost — your data is safe on this device and the next sync will try again. only 300 MB free · 4:12 PM
  • Failed — Cloud sync rejected this device's token (401). Re-enter the server token in Data Sources — retrying won't help. · 4:12 PM

A failure is now prefixed, not merely worded differently: success and failure used to produce the same shape of sentence with the same trailing timestamp, so …returned an error (507) · 4:12 PM was easy to read at a glance as "it synced at 4:12" — and during the outage this string and its timestamp were the only signals available.

5. A pull that applied edits now uploads unconditionally. Found while tracing the same "a real change never reached the server" theme, and it is a genuine hole, not a hypothetical:

  • contentToken() keys workout on COUNT alone. fix_workout tombstones the original, upserts the corrected copy under the noop-cloud device, then deletes the original — +1 then −1 — and touches no other table the token reads. Token byte-identical.
  • It keys sleepSession on (COUNT, MAX(endTs)). adjust_sleep_bounds / edit_sleep_stages UPDATE a row in place, so restaging any night that is not the most recent moves neither. Token byte-identical.

In both cases the skip-unchanged gate silently declined to upload a correction the user had just made. ContentToken.swift's doc comment actively claimed the opposite ("still moves the hrSample/dailyMetric segments in the same edit"); that is false, and it now records the residue instead.

Fixed at the call site, not the fingerprint: summary.applied > 0 || didRecompute is the same fact, already computed, right where the decision is made. Redesigning the token would mean content-hashing tables in an upstream-shared package and would force every device one full re-upload just to change the token's format. The token keeps doing what it is good at — cheaply proving a device with no new samples and no applied edits has nothing to ship.

Deconfliction

Verification

Strand/CloudSync/ is app-target Swift, so no default CI covers it — local builds are the verification.

Check Result
xcodebuild Strand macOS, CLOUD_SYNC set BUILD SUCCEEDED
xcodebuild Strand macOS, flag absent (default offline build) BUILD SUCCEEDED
xcodebuild NOOPiOS, generic/platform=iOS BUILD SUCCEEDED
StrandTests full suite 1071 tests, 1 skipped, 0 failures (1039 on #7 → +32)
swift test in Packages/WhoopStore 290 tests, 0 failures

The flag-absent build is checked deliberately: all of this stays inside #if CLOUD_SYNC, so "fully offline" remains a property of the default binary's bytes. The only change outside the gate is a doc comment in ContentToken.swift.

New tests worth naming:

  • testEachServerStatusMapsToItsOwnTypedError — the whole table, one row per code
  • test503IsOnlyNotConfiguredWhenTheServerSaysSo — the body, not the status, decides
  • testOnlyTransientClassesAreRetryable — the classification the retry engine reads, in one place
  • testUploadCorruptSqliteIsTerminalAndNeverResendsTheBody — exactly one request
  • testUpload507SurfacesAsOutOfSpaceAndIsRetried — exactly two, never three
  • testCancellationIsNeverMistakenForANetworkFault — both shapes land on .cancelled, so a BGAppRefreshTask at expiry never sits out a backoff it cannot survive
  • testACancelledBackoffAbandonsTheRetryLoop — same hazard, from inside the ladder
  • testNotConfiguredServerIsRecordedAsASkipNotAnError / …LeavesTheWatermarkUntouched / testARealDeepBufferFailureIsStillReportedAsAnError — the skip is scoped to one status, not an amnesty
  • testTheWholeStandardLadderIsOverInSecondsNotMinutes — pins the budget against future creep
  • testAFailureIsMarkedAsOneAndASuccessIsNot — the card cannot show a failure as if it synced

CloudSyncRetryPolicy.delayS(beforeAttempt:jitterUnit:) takes the random draw as a parameter and withCloudSyncRetry takes its sleep, so the entire schedule is pure arithmetic — nothing here sleeps and nothing is flaky. Same reasoning as #7's begin(now:).

Cross-platform

No Android twin — Android has no cloud sync at all. The only CloudSync hits under android/ are a Material Icons.Filled.CloudSync on the local backup screen. ContentToken.swift is in an upstream-shared package, but it has no Kotlin counterpart (grep -rn contentToken android/ → nothing) because it exists solely to serve this fork-only skip-unchanged gate — and the change there is a doc comment, altering no stored bytes and no computed value.

…trying

The client folded every non-2xx into one `badResponse(Int, String)` case and
never retried anything. The noop-cloud server is deliberately expressive about
which kind of "no" it is answering — its own `/ingest` handler says so in a
comment — and all of it was thrown away before it reached a human.

- 507 `insufficient_space` means "your backup is fine, the server has no room,
  retry later". It read as "returned an error (507)", i.e. indistinguishable
  from a 400.
- 400 `corrupt_sqlite` means "never send these bytes again". Same sentence.
- 503 `storage_not_configured` on `/deepbuf` means an OPTIONAL archive lane was
  never switched on. It appeared next to a real `/ingest` failure with nothing
  to tell them apart, so one broken thing read as two.
- 401 means the token is wrong and retrying cannot help. Same sentence again.

And with no retry at all, a single 500 from a restarting server ended the whole
sync; the next attempt was not seconds later but whenever the 20h `autoSyncIfDue`
or 4h `backgroundSyncIfDue` window next opened — on a process that in practice
runs `autoSyncIfDue` once per launch. One unlucky second cost a day.

Split `CloudSyncError` into typed cases with a retryable/terminal classification
and messages that say what happens next, add a bounded exponential backoff with
a jitter band for the transient classes only, and treat a not-configured
deep-buffer lane as a skip rather than a failure.

The retry budget is deliberately small (~4s worst case, one retry only for the
100-300MB `/ingest` body): the target cadence is a sync on every phone unlock,
so giving up early costs one more unlock, not one more day. `CloudSyncClient`'s
1h request ceiling and `CloudSyncGate`'s 2h stale reclaim both sit far above it,
so the ladder can never be what wedges a sync.

Also: a pull that applied edits now uploads unconditionally. `contentToken()`
keys `workout` on COUNT alone and `sleepSession` on (count, MAX(endTs)), so
`fix_workout` (tombstone, upsert, delete — +1 then -1) and restaging any night
that is not the most recent both leave the token byte-identical, and the
skip-unchanged gate then silently declined to upload a real correction.
`ContentToken.swift`'s doc comment claimed such an edit "still moves the
hrSample/dailyMetric segments"; it does not, and now says so.
vishk23 pushed a commit that referenced this pull request Aug 24, 2026
"Duplicate as manual" is offered only on read-only rows - strap, Apple, lifting,
activity file - and the menu describes it as a copy path that leaves the original
alone. It did not.

The copy is built with source "manual" so the form treats it as editable, and the
sheet hands that pre-fill straight back as `replacing`. It claims to be a manual
row while carrying the ORIGINAL's natural key, so a save with a changed sport or
start reached three different consumers, each of which then acted on the original:

  - the store deleted the old key under the active strap id, where a
    strap-recorded session lives;
  - Apple's #10 GPS route re-key read the route under the original's key, stored
    it under the duplicate's, and REMOVED it from the original - the source
    session's map and distance vanish;
  - Android's Health Connect write-back deleted "noop-workout-<startTs>", which
    carries no deviceId at all, so the original's records went and a duplicate
    saved at a new start left them deleted with nothing to restore them.

Android already had a guard for exactly this - pass `replacing` only for a MANUAL
or DETECTED row - and it could never work, because it tested the copy's source
and the copy says "manual" by design. Apple had no guard.

Both platforms now carry the distinction where a row's source cannot express it:
an isCopy flag on the sheet target, and `replacing` nil for a duplicate. That
stops it reaching every consumer rather than making one consumer's key safe,
which is what the earlier namespace re-seed did (#1489) - sound for the store,
silent about everything downstream.

Health Connect write-back is opt-in, so that face only affected users who enabled
it. The route and store faces did not need it.

Android: 5 tests - a duplicate replaces nothing despite reading as manual, a real
manual edit and a detected bout still do, an imported row is still refused
without the flag, and a fresh add replaces nothing either way. Extracted as
WorkoutEditing.replacingRowFor so the decision is testable rather than sitting
inside a Composable. Apple compiled via app-build, both legs.

On hardware: duplicate a strap session, change its sport, confirm the original
keeps its route, its distance and its Health Connect record.
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