Skip to content

fix(mobile): show a store timeout separately from a missing credit catalog - #8458

Merged
iscekic merged 1 commit into
mainfrom
drain/credit-store-timeout-copy
Oct 22, 2026
Merged

iscekic merged 1 commit into
mainfrom
drain/credit-store-timeout-copy

Conversation

@iscekic

@iscekic iscekic commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

What changes

The credit-pack loader bounded the store price lookup with the shared 15 s store
deadline, but its catch was untyped: a deadline, a store failure and a catalog
that priced no pack all became one StoreCreditProductsError built from the
no-matching-products copy. The card therefore told the reader the store had
returned no matching packs when the store had simply never answered, and the
report filed that expected, bounded outcome as an error.

  • isStoreDeadlineError is exported from the deadline module, so a caller can
    keep the deadline the module documents ("distinct from a store that answered
    with a failure") apart from a store failure.
  • loadStoreCreditProducts now picks the card body by outcome: a missed
    deadline gets the new timeout copy, a store failure gets the existing
    per-store connection copy, and only a store answer that priced no pack keeps
    the catalog copy.
  • StoreCreditProductsError derives its telemetry from the caught cause: a
    deadline files as warning under credit-catalog-query /
    store-timeout / <storefront> instead of an error-level store-error.
  • The store fetch accepts an AbortSignal. The loader aborts it once the call
    settles without an answer, and the IAP owner checks it before and after its
    NotPrepared reconnect, so a store that already missed the deadline is not
    asked a second time.
  • New copy credits.storeTimedOut / credits.storeTimedOutPlay, translated
    into every catalog, alongside the existing per-store no-match keys.

The screen needs no change: it renders the key the loader authors.

End-to-end evidence

Android emulator (omp36play, Play system image) against the local dev stack on
this VM, signed in as a throwaway local account seeded in the dev database, on
the branch build served by Metro:

  • Timeout state. With the store price lookup locally stubbed (uncommitted,
    dev-only) to never answer, the credits screen shows the new state after the
    15 s deadline: title Google Play products unavailable, body
    Google Play didn't respond in time. Try again., a Try again action, and the
    four backend pack amounts kept on screen as Price unavailable.
    uiautomator dump and OCR of the capture both read that copy. The loader,
    deadline module, screen and catalogs under test are the committed ones; only
    the store boundary was stubbed.
  • Store failure state. With no stub in the bundle, the real Play billing
    client on the emulator fails the price lookup: the same card now reads
    Could not connect to Google Play. Check your connection and try again.
    instead of claiming no matching packs. Same dump/OCR verification.
  • The zero-match state keeps the No matching credit packs were returned by Google Play. copy and is covered by the mounted screen suite (a store answer
    that prices nothing), since the emulator cannot make Play answer with
    unrelated listings.

Captures are attached in the evidence comment on this pull request.

Checks run

pnpm --filter kilo-app check:i18n        (87 catalogs match en.json)
pnpm --filter kilo-app check:plurals
pnpm --filter kilo-app typecheck         (clean, after building @kilocode/trpc and @kilocode/harness-sdk)
pnpm --filter kilo-app lint              (0 errors)
pnpm --filter kilo-app exec vitest run src/lib/credits src/lib/iap/store-call-deadline
pnpm --filter kilo-app exec vitest run --project mobile-mounted src/components/credits src/lib/credits
pnpm --filter kilo-app test              (full mobile suite)

New tests cover: a deadline maps to the timeout key with the deadline as
cause and a warning store-timeout fingerprint; a store failure maps to the
connection key with the error store-error fingerprint; the loader aborts the
signal it gave up on; the IAP owner does not re-ask an aborted call, including
when the abort lands during the reconnect; and the screen renders the timeout
copy after the deadline instead of the catalog copy.

Proxy answers

The owner's proxy gave these answers in the owner's place.

  1. Question: Timeout copy: The credit card currently shows "No matching credit packs were returned by Google Play" for a 15 s timeout. Which copy should a timeout show?
    Answer: New per-store keys credits.storeTimedOut/_Play
    Confidence: 0.55 · Precedent: Keep similar elements consistent across surfaces: the existing connection copy is already per-store (couldNotConnectToAppStore/Play). Localize every string in the mobile app, so the translation pass is required, not a cost to avoid.
  2. Question: Store failure copy: A genuine store SDK failure (not a timeout, not a zero-match answer) currently also shows the no-matching-products copy. What should it show?
    Answer: couldNotConnectToAppStore/Play
    Confidence: 0.85 · Precedent: Fix the root cause and the class of the issue; a genuine failure should not claim a catalog mismatch.
  3. Question: Deadline telemetry: A store deadline currently files as error-level under fingerprint credit-catalog-query/store-error. How should it file once it is distinguished?
    Answer: Warning + store-timeout fingerprint
    Confidence: 0.80 · Precedent: If you see a gap or an error, fix it. Make alerts informative and actionable.
  4. Question: Retry after deadline: The IAP owner's fetchStoreProducts reconnects and asks the store again when the first call fails as NotPrepared. If the loader's 15 s deadline already settled, that second call runs after the deadline — the contract says a caller must not follow a deadline with more calls to a hung store. Fix it in this PR?
    Answer: Pass an AbortSignal and skip the retry
    Confidence: 0.80 · Precedent: Fix the root cause and the class of the issue on every affected surface. The brief explicitly asks that callers avoid re-calling a store that missed the deadline.

The credit-pack loader caught every store rejection the same way, so a price
lookup that missed the 15 s deadline showed the no-matching-products copy and
filed as an error-level store failure.

The loader now keeps the deadline module's distinction: a deadline maps to its
own per-store timeout copy, a store that failed maps to the existing connection
copy, and only a store answer that priced no pack keeps the catalog copy. A
deadline reports under its own warning fingerprint (store-timeout) instead of
store-error. The new copy is translated into every catalog.

The store fetch also accepts an AbortSignal that the loader aborts once the
call settles without an answer, so the Play NotPrepared reconnect does not ask
a store that already missed the deadline a second time.
@iscekic iscekic added drain Work item from the findings drain janitor:sentry Findings drain: Sentry-derived janitor work items labels Oct 21, 2026
@iscekic

iscekic commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

bot: Android proof from the branch build on the local dev stack (Play system image, throwaway local account).

  1. Store price lookup does not answer inside the 15 s deadline: the credits card names the timeout instead of claiming the store returned no matching packs, keeps the four pack amounts on screen as price-unavailable, and offers Try again. The store boundary was locally stubbed (uncommitted) to never answer; the deadline, loader mapping, screen and catalogs are the committed code.
  2. With no stub, the real Play billing client on the emulator fails the lookup: the same card now shows the connection copy, not the catalog copy.
    Both states were confirmed with a UI dump and OCR of these captures.

Credits card after a store deadline: timeout body and Try again

Credits card after a store call failure: connection body and Try again

@kilo-code-bot

kilo-code-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The three store outcomes are now distinguished correctly (deadline → timeout copy and warning fingerprint, failure → connection copy and error fingerprint, zero-match → catalog copy), the abort signal only short-circuits the Play reconnect path, and all 87 catalogs carry matching, store-correct copy; I found no runtime, security, or leak issue in the changed code.

Assumptions: [INFERENCE] Automated checks (check:i18n, typecheck, lint, and the vitest suites) were reported green in the PR body and not re-run here; the emulator evidence is the author's and was not independently reproduced. The new AbortSignal path was reviewed by reading the owner/loader only — the expo-iap fetch itself remains uncancellable, so the change bounds retries rather than the original hanging call.

Files Reviewed (95 files)
  • apps/mobile/src/components/credits/credit-native-iap-owner.tsx - signal threaded into fetchStoreProducts; abort checked before and after the reconnect, so a settled deadline skips the second ask
  • apps/mobile/src/components/credits/credit-native-iap-owner.mounted.test.tsx - abort-before-call and abort-during-reconnect coverage; failure copy expectation updated
  • apps/mobile/src/components/credits/credit-purchase-screen.mounted.test.tsx - failure, timeout, and zero-match copy each asserted on the storefront they belong to
  • apps/mobile/src/lib/credits/store-products-loader.ts - storeCallFailureKey maps deadline vs failure vs zero-match; telemetry splits store-timeout (warning) from store-error; per-call AbortController aborted on settle-without-answer (no listener or timer retained)
  • apps/mobile/src/lib/credits/store-products-loader.test.ts - timeout key, cause, warning fingerprint, abort, and copy-key coverage; Promise.race handlers keep the late store rejection handled
  • apps/mobile/src/lib/credits/store-products.ts - optional StoreProductFetchOptions.signal; no other caller breaks
  • apps/mobile/src/lib/credits/use-store-credit-products.ts - connection-bound keys now imported instead of duplicated; behavior unchanged
  • apps/mobile/src/lib/iap/store-call-deadline.ts - isStoreDeadlineError type guard over the existing deadline class; timer still cleared in finally
  • apps/mobile/src/i18n/locales/*.json (87 catalogs) - credits.storeTimedOut / storeTimedOutPlay present exactly once per catalog, key order matches English, App Store and Google Play references are not swapped, and only en.json holds the English wording

Reviewed by deepseek-flash-latest · Input: 95.3K · Output: 21.8K · Cached: 2.3M

Review guidance: REVIEW.md from base branch main

@iscekic iscekic added merge:bot Bot-owned review; merge when ready once readiness passes and a review approves. merge:blocked:insufficient-e2e Verification does not cover the change labels Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drain Work item from the findings drain janitor:sentry Findings drain: Sentry-derived janitor work items merge:bot Bot-owned review; merge when ready once readiness passes and a review approves.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants