fix(shopinbit): back off pollers on error and pause when backgrounded
What changed, and why it matters
This commit fixes a bug in Stack Wallet's ShopInBit feature where the app kept asking the server for updates every 15 or 30 seconds, even when the server was already saying 'slow down' (HTTP 429) or the request failed. The fix adds a backoff that doubles the wait time after failures, pauses polling when the app is in the background, and starts logging errors that were previously silently ignored. It is a defensive hardening change, not an active vulnerability being exploited.
Treat as a reliability and minor security hardening fix. No urgent user action is required. Review whether server-side rate limits are also enforced independently of client behavior, and consider centralizing polling/backoff logic to avoid future inconsistencies.
Security signals we found
Rate-limit/429 amplification via fixed-interval polling
Silent swallowing of poll exceptions removed
Background polling continues unnecessarily without lifecycle awareness
Exponential backoff added for failure cases
No authentication, cryptographic, or input-validation changes observed
Evidence from the diff
Three ShopInBit views (car-research payment, payment, ticket detail) previously used fixed-rate Timer.periodic polling (15s or 30s) and silently swallowed exceptions. On API/rate-limit failures (e.g., HTTP 429), the timers continued firing at full rate, re-provoking the failure. The patch replaces fixed periodic timers with self-scheduling Timers that double the interval on failure (capped at 120s) and reset on success. It also adds WidgetsBindingObserver lifecycle handling to cancel polling while the app is backgrounded and resume on foreground. Poll errors are now logged via Logging.instance.
Changed components
lib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_ticket_detail.dartInspect captured patch +149 / −21
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index 7f34843..d8f78bd 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -42,8 +42,14 @@ class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget {
}
class _ShopInBitCarResearchPaymentViewState
- extends ConsumerState<ShopInBitCarResearchPaymentView> {
+ extends ConsumerState<ShopInBitCarResearchPaymentView>
+ with WidgetsBindingObserver {
Timer? _pollTimer;
+
+ static const Duration _kBasePollInterval = Duration(seconds: 15);
+ static const Duration _kMaxPollInterval = Duration(seconds: 120);
+ Duration _pollInterval = _kBasePollInterval;
+
Map<String, dynamic>? _status;
_PaymentFlowState _flowState = _PaymentFlowState.idle;
String _statusString = "ready_to_pay";
@@ -183,23 +189,59 @@ class _ShopInBitCarResearchPaymentViewState
@override
void initState() {
super.initState();
+ WidgetsBinding.instance.addObserver(this);
final links = widget.invoice.paymentLinks;
_methods = links.keys.map((k) => k.toUpperCase()).toList();
_addresses = links.values.toList();
// Kick off an immediate poll then start periodic polling.
unawaited(_pollStatus());
- _pollTimer = Timer.periodic(
- const Duration(seconds: 15),
- (_) => unawaited(_pollStatus()),
- );
+ _scheduleNextPoll();
}
@override
void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
_pollTimer?.cancel();
super.dispose();
}
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ // Don't poll while backgrounded; resume fresh when we come back.
+ if (state == AppLifecycleState.resumed) {
+ if (!_isTerminal && _flowState != _PaymentFlowState.finalizing) {
+ _pollInterval = _kBasePollInterval;
+ _scheduleNextPoll();
+ }
+ } else {
+ _pollTimer?.cancel();
+ }
+ }
+
+ void _scheduleNextPoll() {
+ _pollTimer?.cancel();
+ _pollTimer = Timer(_pollInterval, _pollTick);
+ }
+
+ Duration _nextBackoff(Duration current) {
+ final Duration next = current * 2;
+ return next > _kMaxPollInterval ? _kMaxPollInterval : next;
+ }
+
+ /// Periodic driver: poll once, then reschedule with backoff on failure and
+ /// reset on success. Stops once the flow is terminal or finalizing.
+ Future<void> _pollTick() async {
+ final bool ok = await _pollStatus();
+ if (!mounted) return;
+ if (_isTerminal ||
+ _flowState == _PaymentFlowState.finalizing ||
+ _flowState == _PaymentFlowState.complete) {
+ return;
+ }
+ _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval);
+ _scheduleNextPoll();
+ }
+
void _popToTickets() {
Navigator.of(context).popUntil((route) {
final name = route.settings.name;
@@ -266,7 +308,9 @@ class _ShopInBitCarResearchPaymentViewState
}
}
- Future<void> _pollStatus() async {
+ /// Fetch invoice status once and apply it. Returns false on any failure so
+ /// the periodic driver can back off instead of polling at full rate.
+ Future<bool> _pollStatus() async {
try {
final resp = await ref
.read(pShopinBitService)
@@ -283,9 +327,9 @@ class _ShopInBitCarResearchPaymentViewState
),
);
}
- return;
+ return false;
}
- if (!mounted) return;
+ if (!mounted) return true;
Logging.instance.i(
"CarResearch status response (payment_view): ${resp.value}",
);
@@ -302,6 +346,7 @@ class _ShopInBitCarResearchPaymentViewState
_pollTimer?.cancel();
await _finalizePayment();
}
+ return true;
} catch (e, s) {
Logging.instance.e(
"ticket status polling issue",
@@ -317,6 +362,7 @@ class _ShopInBitCarResearchPaymentViewState
),
);
}
+ return false;
}
}
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index f3b7ccf..f306982 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -15,6 +15,7 @@ import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/assets.dart';
+import '../../utilities/logger.dart';
import '../../utilities/show_loading.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
@@ -50,10 +51,15 @@ class ShopInBitPaymentView extends ConsumerStatefulWidget {
_ShopInBitPaymentViewState();
}
-class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
+class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
+ with WidgetsBindingObserver {
int _selectedMethod = 0;
Timer? _pollTimer;
+ static const Duration _kBasePollInterval = Duration(seconds: 15);
+ static const Duration _kMaxPollInterval = Duration(seconds: 120);
+ Duration _pollInterval = _kBasePollInterval;
+
PaymentInfo? _paymentInfo;
// Derived from API payment_links keys, fallback to defaults
@@ -81,6 +87,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
@override
void initState() {
super.initState();
+ WidgetsBinding.instance.addObserver(this);
_applyPaymentInfo(widget.paymentInfo);
if (widget.apiTicketId != 0) {
_startPolling();
@@ -89,10 +96,22 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
@override
void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
_pollTimer?.cancel();
super.dispose();
}
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (widget.apiTicketId == 0) return;
+ // Don't poll while backgrounded; resume fresh when we come back.
+ if (state == AppLifecycleState.resumed) {
+ if (!_isTerminal) _startPolling();
+ } else {
+ _pollTimer?.cancel();
+ }
+ }
+
void _applyPaymentInfo(PaymentInfo info) {
_paymentInfo = info;
final links = info.paymentLinks;
@@ -104,25 +123,49 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
void _startPolling() {
_pollTimer?.cancel();
- _pollTimer = Timer.periodic(
- const Duration(seconds: 15),
- (_) => _pollPayment(),
- );
+ _pollInterval = _kBasePollInterval;
+ _scheduleNextPoll();
+ }
+
+ void _scheduleNextPoll() {
+ _pollTimer?.cancel();
+ _pollTimer = Timer(_pollInterval, _pollPayment);
+ }
+
+ Duration _nextBackoff(Duration current) {
+ final Duration next = current * 2;
+ return next > _kMaxPollInterval ? _kMaxPollInterval : next;
}
Future<void> _pollPayment() async {
+ bool ok = false;
try {
final resp = await ref
.read(pShopinBitService)
.client
.getPayment(widget.apiTicketId);
- if (!resp.hasError && resp.value != null && mounted) {
- setState(() => _applyPaymentInfo(resp.value!));
- if (_isTerminal) {
- _pollTimer?.cancel();
+ if (!resp.hasError && resp.value != null) {
+ ok = true;
+ if (mounted) {
+ setState(() => _applyPaymentInfo(resp.value!));
}
}
- } catch (_) {}
+ } catch (e, s) {
+ Logging.instance.w(
+ "ShopInBit payment poll failed",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ if (!mounted) return;
+ if (_isTerminal) {
+ _pollTimer?.cancel();
+ return;
+ }
+ // Back off on failure (e.g. a 429), reset to base on success, so a rate
+ // limit slows us down instead of getting hammered every 15s.
+ _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval);
+ _scheduleNextPoll();
}
Future<void> _refreshInvoice() async {
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
index 8424551..49fac0e 100644
--- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -13,6 +13,7 @@ import '../../services/shopinbit/src/models/message.dart';
import '../../services/shopinbit/src/models/ticket.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/assets.dart';
+import '../../utilities/logger.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../widgets/background.dart';
@@ -38,9 +39,14 @@ class ShopInBitTicketDetail extends ConsumerStatefulWidget {
_ShopInBitTicketDetailState();
}
-class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
+class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
+ with WidgetsBindingObserver {
late final TextEditingController _messageController;
+ static const Duration _kBasePollInterval = Duration(seconds: 30);
+ static const Duration _kMaxPollInterval = Duration(seconds: 120);
+ Duration _pollInterval = _kBasePollInterval;
+
// Optimistically-shown messages the user just sent, kept until the next
// refresh folds them into the persisted ticket row.
final List<TicketMessage> _pending = [];
@@ -54,6 +60,7 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
super.initState();
_messageController = TextEditingController();
+ WidgetsBinding.instance.addObserver(this);
// start with a refresh right away and then start polling for updates
unawaited(_refresh().then((_) => _startPolling()));
@@ -61,15 +68,39 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
@override
void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
_pollingTimer?.cancel();
_pollingTimer = null;
_messageController.dispose();
super.dispose();
}
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ // Don't poll while backgrounded; resume fresh when we come back.
+ if (state == AppLifecycleState.resumed) {
+ final ticket = ref.read(pShopInBitTicket(_id)).asData?.value;
+ final terminal =
+ ticket != null && TicketState.fromString(ticket.statusRaw).isTerminal;
+ if (!terminal) _startPolling();
+ } else {
+ _pollingTimer?.cancel();
+ }
+ }
+
Timer? _pollingTimer;
Future<void> _poll() async {
- await _refresh();
+ bool ok = false;
+ try {
+ await _refresh();
+ ok = true;
+ } catch (e, s) {
+ Logging.instance.w(
+ "ShopInBit ticket poll failed",
+ error: e,
+ stackTrace: s,
+ );
+ }
if (!mounted) return;
// Stop polling once the ticket reaches a terminal state; nothing about a
@@ -79,11 +110,19 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
return;
}
- _pollingTimer = Timer(const Duration(seconds: 30), _poll);
+ // Back off on failure (e.g. a 429), reset on success.
+ _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval);
+ _pollingTimer = Timer(_pollInterval, _poll);
+ }
+
+ Duration _nextBackoff(Duration current) {
+ final Duration next = current * 2;
+ return next > _kMaxPollInterval ? _kMaxPollInterval : next;
}
void _startPolling() {
_pollingTimer?.cancel();
+ _pollInterval = _kBasePollInterval;
unawaited(_poll());
}
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.