In our Flutter app (Android and iOS, in_app_purchase 3.3.1) we shipped a bug where the shop's price list stayed on "Loading" forever. The cause was simple: if connecting to the store or querying products failed once, nothing ever tried again until the user restarted the app.
Here is what was wrong, and the retry design we replaced it with.
What was going on
All billing code lives in a Billing singleton with three states: loading, ready and unavailable. The old refresh() looked roughly like this:
Future<void> refresh() async {
final gw = _gw;
if (gw == null || status.value == BillingStatus.loading) return; // not connected, or busy: drop it
status.value = BillingStatus.loading;
try {
if (!await gw.isAvailable().timeout(const Duration(seconds: 15))) {
status.value = BillingStatus.unavailable; return; // one failure ends it
}
final q = await gw.query(ProductIds.all).timeout(const Duration(seconds: 20));
_products = {for (final e in q.found.entries) e.key: e.value}; // full overwrite
} catch (e) { status.value = BillingStatus.unavailable; } // no retry
}
That has four holes:
-
A failed connection at startup is permanent. If creating the gateway threw at launch,
_gwstayednull, and every laterrefresh()returned on the first line. -
Requests during a query were dropped. A refresh that arrived while
loadingwas simply ignored. -
No automatic retry.
isAvailable() == false, a timeout and an exception all ended inunavailable. The only retry was opening the shop screen. - Partial results overwrote good data. A query that returned only some products could erase products we already had.
Temporary store failures are common: the Play Store app updating itself, a network blip. The real problem was that one failure meant "Loading" forever.
The fix
1. Reconnect on the next attempt
If the connection fails at startup we don't give up. ensureStarted() reconnects on every new attempt, and syncs past purchases once it connects.
2. Don't drop requests during a query — queue one more
Future<void> refresh() {
final cur = _inflight;
if (cur != null) { _again = true; return cur; } // run once more after this one
// ...
}
However many times it is called, at most one extra query runs after the current one.
3. Merge results instead of overwriting
_products = {..._products, ...found}; // a partial answer can't erase known products
4. Retry automatically at 5 s → 30 s → 2 min, then stop
static List<Duration> retryDelays =
const [Duration(seconds: 5), Duration(seconds: 30), Duration(minutes: 2)];
void _afterRefresh() {
if (!needsRetry) { _retryStep = 0; return; } // got everything: reset
if (_retryStep >= retryDelays.length) return; // stop after 3
_retryTimer = Timer(retryDelays[_retryStep++], () => unawaited(refresh()));
}
We cap it on purpose. On devices where the store really is unavailable (no Play Store, for example), retrying forever only burns battery and data. Timeouts stay at 15 s for the connection check and 20 s for the query.
5. Let user actions and app resume trigger a retry
If the three automatic tries fail, four other things trigger a retry: app resume, opening the shop, pressing a buy button, and a manual retry.
// main.dart
AppLifecycleListener(onResume: Billing.i.onResume);
// billing.dart
void onResume() { if (needsRetry) unawaited(retryNow()); } // only when products are missing
We don't listen to a connectivity stream. Timers handle the automatic part; everything else comes from the user or from resume. A manual trigger resets the retry schedule to zero. If the buy button is pressed while billing isn't ready, it calls retryNow() and then continues straight into the purchase.
6. Stop saying "Loading"
We split failures into connect, timeout and notFound, and the screen now says "Connecting to the store… / Retry" instead of "Loading". With "Loading", users can't tell whether waiting will help or whether the item is gone. The first time each failure type appears we log an analytics event, so we can see later which failures are common.
Takeaways
- Write billing setup so it can be redone any number of times, not "once at startup".
- Don't drop requests that arrive mid-query; queue exactly one more.
- Merge query results so a partial answer can't delete known products.
- Cap automatic retries; leave the rest to app resume and user actions.
- Don't show a vague state like "Loading" when you know what failed.
This shipped in version 1.1.1 on Android and iOS. We haven't yet verified every real-payment path in production, so if your setup is similar, try it with a Play Console license tester: toggle airplane mode and force-stop the Play Store app while the shop is open.
Disclosure: I'm the developer of this app, **Starlit Alley: Tarot & Saju* (HS Solution). Core readings (saju, tarot, dream reading) are free, with no sign-up and no ads. Google Play*
This post was drafted by an AI agent from our repository and fix notes. The code and numbers are quoted from the actual repository.
Top comments (1)
If you want to see the shop screen this fixed, the app is free on Google Play (no sign-up): https://play.google.com/store/apps/details?id=com.noligo.saju_app&referrer=utm_source%3Ddevto%26utm_medium%3Dcomment%26utm_campaign%3Dbilling-retry
Question for other Flutter devs: do you retry
queryProductDetailson a timer like this, or only on app resume / user action?