fix(shopinbit): retry 429s with backoff at the request chokepoint
What changed, and why it matters
This commit fixes a reliability issue in the Stack Wallet app's integration with ShopinBit. Previously, when the ShopinBit server was overwhelmed and returned a '429 Too Many Requests' error, the app would immediately give up and let the caller retry right away, potentially hammering the server even harder. Now, the app automatically waits and retries up to three times, respecting the server's 'Retry-After' instruction or using a sensible backoff delay. This is a defensive improvement that reduces accidental denial-of-service behavior and improves request success rates under load.
No immediate action required; this is a defensive hardening patch. Reviewers may want to verify that the 3-retry/30s-cap policy aligns with ShopinBit API terms, and confirm that callers above `_send` do not implement their own uncoordinated retry loops that could reintroduce thundering-herd behavior.
Security signals we found
Adds rate-limit retry/backoff at centralized API client chokepoint
Exposes HTTP response headers to enable Retry-After parsing
Caps maximum backoff and retry count to prevent unbounded delays
Uses jittered exponential backoff to avoid thundering-herd retries
Treats negative or malformed Retry-After values defensively
Evidence from the diff
The patch adds HTTP 429 retry logic with exponential backoff and jitter at the single request chokepoint (_send) in lib/services/shopinbit/src/client.dart. It also extends the custom Response class in lib/networking/http.dart to expose response headers so Retry-After can be parsed. The retry logic honors server-provided Retry-After values (seconds or HTTP-date), caps delays at 30 seconds, and limits retries to 3 attempts. All HTTP verbs (GET/POST/PUT/PATCH/DELETE) flowing through _send inherit this behavior.
Changed components
lib/networking/http.dartlib/services/shopinbit/src/client.dartInspect captured patch +128 / −34
diff --git a/lib/networking/http.dart b/lib/networking/http.dart
index 246891d..370e315 100644
--- a/lib/networking/http.dart
+++ b/lib/networking/http.dart
@@ -14,9 +14,21 @@ class Response {
final int code;
final List<int> bodyBytes;
+ // Lower-cased response header names mapped to their (comma-joined) values.
+ // Empty by default so existing callers/tests don't need to supply them.
+ final Map<String, String> headers;
+
String get body => utf8.decode(bodyBytes, allowMalformed: true);
- Response(this.bodyBytes, this.code);
+ Response(this.bodyBytes, this.code, {this.headers = const {}});
+}
+
+Map<String, String> _headerMap(HttpClientResponse response) {
+ final map = <String, String>{};
+ response.headers.forEach((name, values) {
+ map[name.toLowerCase()] = values.join(', ');
+ });
+ return map;
}
class HTTP {
@@ -46,7 +58,11 @@ class HTTP {
final response = await request.close();
- return Response(await _bodyBytes(response), response.statusCode);
+ return Response(
+ await _bodyBytes(response),
+ response.statusCode,
+ headers: _headerMap(response),
+ );
} catch (e, s) {
Logging.instance.w("HTTP.get() rethrew: ", error: e, stackTrace: s);
rethrow;
@@ -78,7 +94,11 @@ class HTTP {
request.write(body);
final response = await request.close();
- return Response(await _bodyBytes(response), response.statusCode);
+ return Response(
+ await _bodyBytes(response),
+ response.statusCode,
+ headers: _headerMap(response),
+ );
} catch (e, s) {
Logging.instance.w("HTTP.post() rethrew: ", error: e, stackTrace: s);
rethrow;
@@ -109,7 +129,11 @@ class HTTP {
if (body != null) request.write(body);
final response = await request.close();
- return Response(await _bodyBytes(response), response.statusCode);
+ return Response(
+ await _bodyBytes(response),
+ response.statusCode,
+ headers: _headerMap(response),
+ );
} catch (e, s) {
Logging.instance.w("HTTP.put() rethrew: ", error: e, stackTrace: s);
rethrow;
@@ -140,7 +164,11 @@ class HTTP {
request.write(body);
final response = await request.close();
- return Response(await _bodyBytes(response), response.statusCode);
+ return Response(
+ await _bodyBytes(response),
+ response.statusCode,
+ headers: _headerMap(response),
+ );
} catch (e, s) {
Logging.instance.w("HTTP.patch() rethrew: ", error: e, stackTrace: s);
rethrow;
@@ -168,7 +196,11 @@ class HTTP {
}
final response = await request.close();
- return Response(await _bodyBytes(response), response.statusCode);
+ return Response(
+ await _bodyBytes(response),
+ response.statusCode,
+ headers: _headerMap(response),
+ );
} catch (e, s) {
Logging.instance.w("HTTP.delete() rethrew: ", error: e, stackTrace: s);
rethrow;
diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart
index c939c5a..f3909ee 100644
--- a/lib/services/shopinbit/src/client.dart
+++ b/lib/services/shopinbit/src/client.dart
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
+import 'dart:math';
import '../../../app_config.dart';
import '../../../networking/http.dart';
@@ -19,6 +20,10 @@ import 'token_manager.dart';
const _kTag = "ShopInBitClient";
+// 429 retry policy: up to 3 retries, backoff capped at 30s.
+const int _kMaxRetries = 3;
+const Duration _kMaxBackoff = Duration(seconds: 30);
+
class ShopInBitClient {
final String accessKey;
final String partnerSecret;
@@ -26,6 +31,7 @@ class ShopInBitClient {
final bool sandbox;
final HTTP _httpClient;
final TokenManager _tokenManager;
+ final Random _rng = Random();
String? _externalCustomerKey;
@@ -578,34 +584,90 @@ class ShopInBitClient {
Logging.instance.t("$_kTag $method $uri");
- switch (method) {
- case 'GET':
- return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy);
- case 'POST':
- return _httpClient.post(
- url: uri,
- headers: headers,
- body: body != null ? _asciiSafeJson(body) : null,
- proxyInfo: proxy,
- );
- case 'PUT':
- return _httpClient.put(
- url: uri,
- headers: headers,
- body: body != null ? jsonEncode(body) : null,
- proxyInfo: proxy,
- );
- case 'PATCH':
- return _httpClient.patch(
- url: uri,
- headers: headers,
- body: body != null ? _asciiSafeJson(body) : null,
- proxyInfo: proxy,
- );
- case 'DELETE':
- return _httpClient.delete(url: uri, headers: headers, proxyInfo: proxy);
- default:
- throw ApiException('Unsupported method: $method');
+ Future<Response> dispatch() {
+ switch (method) {
+ case 'GET':
+ return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy);
+ case 'POST':
+ return _httpClient.post(
+ url: uri,
+ headers: headers,
+ body: body != null ? _asciiSafeJson(body) : null,
+ proxyInfo: proxy,
+ );
+ case 'PUT':
+ return _httpClient.put(
+ url: uri,
+ headers: headers,
+ body: body != null ? jsonEncode(body) : null,
+ proxyInfo: proxy,
+ );
+ case 'PATCH':
+ return _httpClient.patch(
+ url: uri,
+ headers: headers,
+ body: body != null ? _asciiSafeJson(body) : null,
+ proxyInfo: proxy,
+ );
+ case 'DELETE':
+ return _httpClient.delete(
+ url: uri,
+ headers: headers,
+ proxyInfo: proxy,
+ );
+ default:
+ throw ApiException('Unsupported method: $method');
+ }
+ }
+
+ // Retry on 429 (Too Many Requests) with backoff so we stop hammering the
+ // API the moment it tells us to. Respects a server-sent Retry-After when
+ // present, otherwise exponential backoff with jitter. Everything funnels
+ // through here, so all endpoints get this for free.
+ int attempt = 0;
+ while (true) {
+ final response = await dispatch();
+ if (response.code != 429 || attempt >= _kMaxRetries) {
+ return response;
+ }
+ final Duration delay = _backoffDelay(attempt, response.headers);
+ Logging.instance.w(
+ "$_kTag $method $resolved HTTP:429, backing off "
+ "${delay.inMilliseconds}ms (retry ${attempt + 1}/$_kMaxRetries)",
+ );
+ await Future<void>.delayed(delay);
+ attempt++;
+ }
+ }
+
+ /// How long to wait before retrying a 429. Prefers a sane `Retry-After`
+ /// header; otherwise 1s, 2s, 4s... with jitter, capped at [_kMaxBackoff].
+ Duration _backoffDelay(int attempt, Map<String, String> headers) {
+ final Duration? retryAfter = _parseRetryAfter(headers['retry-after']);
+ if (retryAfter != null) {
+ return retryAfter > _kMaxBackoff ? _kMaxBackoff : retryAfter;
+ }
+ final int base = 1000 * (1 << attempt);
+ final int ms = base + _rng.nextInt(500);
+ return ms > _kMaxBackoff.inMilliseconds
+ ? _kMaxBackoff
+ : Duration(milliseconds: ms);
+ }
+
+ /// Parse a `Retry-After` value, which is either delay-seconds or an
+ /// HTTP-date. Returns null if absent or unparseable.
+ Duration? _parseRetryAfter(String? value) {
+ if (value == null) return null;
+ final String trimmed = value.trim();
+ final int? seconds = int.tryParse(trimmed);
+ if (seconds != null) {
+ return seconds < 0 ? Duration.zero : Duration(seconds: seconds);
+ }
+ try {
+ final Duration diff = HttpDate.parse(trimmed).difference(DateTime.now());
+ return diff.isNegative ? Duration.zero : diff;
+ } catch (_) {
+ return null;
}
}
Why this scored 29/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.