Skip to content

Commit 648b56e

Browse files
add httpClient with retry
1 parent 970d1f0 commit 648b56e

File tree

7 files changed

+228
-48
lines changed

7 files changed

+228
-48
lines changed

packages/linkfive_purchases/lib/client/linkfive_client.dart

Lines changed: 19 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dart:convert';
22
import 'dart:io';
33
import 'dart:ui';
44

5-
import 'package:http/http.dart' as http;
5+
import 'package:http/http.dart';
66
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
77
import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
88
import 'package:linkfive_purchases/client/linkfive_client_interface.dart';
@@ -11,11 +11,15 @@ import 'package:linkfive_purchases/logic/linkfive_user_management.dart';
1111
import 'package:linkfive_purchases/models/requests/purchase_request_google.dart';
1212
import 'package:linkfive_purchases/models/requests/purchase_request_google_otp.dart';
1313
import 'package:linkfive_purchases/models/requests/purchase_request_pricing_phase.dart';
14+
import 'package:linkfive_purchases/src/client/http/http_client.dart';
15+
import 'package:linkfive_purchases/src/client/http/retry_http_client.dart';
1416
import 'package:linkfive_purchases/store/linkfive_app_data_store.dart';
1517
import 'package:package_info_plus/package_info_plus.dart';
1618

1719
/// HTTP client to LinkFive
1820
class LinkFiveClient extends LinkFiveClientInterface {
21+
late final httpClient = RetryHttpClient(HttpClientFactory.basic());
22+
1923
final String stagingUrl = "api.staging.linkfive.io";
2024
final String prodUrl = "api.linkfive.io";
2125

@@ -79,7 +83,7 @@ class LinkFiveClient extends LinkFiveClientInterface {
7983
Future<LinkFiveResponseData> fetchLinkFiveResponse() async {
8084
final uri = _makeUri("api/v1/subscriptions");
8185

82-
final response = await http.get(uri, headers: await _headers);
86+
final response = await httpClient.get(uri, headers: await _headers);
8387
LinkFiveLogger.d('Response status: ${response.statusCode}');
8488
LinkFiveLogger.d('Response body: ${response.body}');
8589
final mapBody = jsonDecode(response.body);
@@ -107,18 +111,9 @@ class LinkFiveClient extends LinkFiveClientInterface {
107111
};
108112

109113
LinkFiveLogger.d("purchase. $body");
110-
try {
111-
final response = await http.post(uri, body: jsonEncode(body), headers: await _headers);
112-
113-
return _parseOneTimePurchaseListResponse(response);
114-
} catch (e) {
115-
LinkFiveLogger.e("Purchase Request Error: ${e.toString()}");
116-
LinkFiveLogger.e("Try Again with same request");
114+
final response = await httpClient.post(uri, body: body, headers: await _headers);
117115

118-
final response = await http.post(uri, body: jsonEncode(body), headers: await _headers);
119-
120-
return _parseOneTimePurchaseListResponse(response);
121-
}
116+
return _parseOneTimePurchaseListResponse(response);
122117
}
123118

124119
/// after a purchase on Google we call the purchases/google
@@ -151,18 +146,10 @@ class LinkFiveClient extends LinkFiveClientInterface {
151146

152147
LinkFiveLogger.d("purchase: $purchaseBody");
153148

154-
try {
155-
final response = await http.post(uri, body: jsonEncode(purchaseBody.toJson()), headers: await _headers);
156-
157-
return _parseOneTimePurchaseListResponse(response);
158-
} catch (e) {
159-
LinkFiveLogger.e("Purchase Request Error: ${e.toString()}");
160-
LinkFiveLogger.e("Try Again with same request");
161-
162-
final response = await http.post(uri, body: jsonEncode(purchaseBody.toJson()), headers: await _headers);
149+
final response =
150+
await httpClient.post(uri, body: purchaseBody.toJson(), headers: await _headers);
163151

164-
return _parseOneTimePurchaseListResponse(response);
165-
}
152+
return _parseOneTimePurchaseListResponse(response);
166153
}
167154

168155
@override
@@ -178,18 +165,10 @@ class LinkFiveClient extends LinkFiveClientInterface {
178165
);
179166

180167
LinkFiveLogger.d("purchase: $purchaseBody");
181-
try {
182-
final response = await http.post(uri, body: jsonEncode(purchaseBody.toJson()), headers: await _headers);
183-
184-
return _parseOneTimePurchaseListResponse(response);
185-
} catch (e) {
186-
LinkFiveLogger.e("Purchase Request Error: ${e.toString()}");
187-
LinkFiveLogger.e("Try Again with same request");
168+
final response =
169+
await httpClient.post(uri, body: purchaseBody.toJson(), headers: await _headers);
188170

189-
final response = await http.post(uri, body: jsonEncode(purchaseBody.toJson()), headers: await _headers);
190-
191-
return _parseOneTimePurchaseListResponse(response);
192-
}
171+
return _parseOneTimePurchaseListResponse(response);
193172
}
194173

195174
/// Fetches the receipts for a user
@@ -200,7 +179,7 @@ class LinkFiveClient extends LinkFiveClientInterface {
200179
Future<LinkFiveActiveProducts> fetchUserPlanListFromLinkFive() async {
201180
final uri = _makeUri("api/v1/purchases/user");
202181

203-
final response = await http.get(uri, headers: await _headers);
182+
final response = await httpClient.get(uri, headers: await _headers);
204183
return _parseOneTimePurchaseListResponse(response);
205184
}
206185

@@ -221,7 +200,7 @@ class LinkFiveClient extends LinkFiveClientInterface {
221200

222201
LinkFiveLogger.d("Restore body: $body");
223202

224-
final response = await http.post(uri, body: jsonEncode(body), headers: await _headers);
203+
final response = await httpClient.post(uri, body: body, headers: await _headers);
225204
return _parseOneTimePurchaseListResponse(response);
226205
}
227206

@@ -242,15 +221,15 @@ class LinkFiveClient extends LinkFiveClientInterface {
242221

243222
LinkFiveLogger.d("Restore body: $body");
244223

245-
final response = await http.post(uri, body: jsonEncode(body), headers: await _headers);
224+
final response = await httpClient.post(uri, body: body, headers: await _headers);
246225
return _parseOneTimePurchaseListResponse(response);
247226
}
248227

249228
@override
250229
Future<LinkFiveActiveProducts> changeUserId(String? userId) async {
251230
final uri = _makeUri("api/v1/purchases/user/customer-user-id");
252231

253-
final response = await http.put(uri, headers: await _headers);
232+
final response = await httpClient.put(uri, headers: await _headers);
254233
return _parseOneTimePurchaseListResponse(response);
255234
}
256235

@@ -263,7 +242,7 @@ class LinkFiveClient extends LinkFiveClientInterface {
263242
return Uri.https(hostUrl, path, queryParams);
264243
}
265244

266-
LinkFiveActiveProducts _parseOneTimePurchaseListResponse(http.Response response) {
245+
LinkFiveActiveProducts _parseOneTimePurchaseListResponse(Response response) {
267246
LinkFiveLogger.d("Parse with body ${response.body}");
268247

269248
Map<String, dynamic> jsonResponse = jsonDecode(response.body);

packages/linkfive_purchases/lib/logic/linkfive_purchases_impl.dart

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ class LinkFivePurchasesImpl extends DefaultPurchaseHandler implements CallbackIn
6161

6262
//#region Members
6363

64-
/// Cache of AppStoreProductDetail
65-
ProductDetails? _productDetailsToPurchase;
64+
/// Saving productDetails for the purchase
65+
ProductDetails? _lastProductDetailsToPurchase;
6666

6767
/// the v2 of purchases waits for the async purchase response and completes the future
6868
final List<Completer<LinkFiveActiveProducts>> registeredPurchaseCompleterList = [];
@@ -238,7 +238,7 @@ class LinkFivePurchasesImpl extends DefaultPurchaseHandler implements CallbackIn
238238

239239
// We're saving the product Details whenever the user purchases a product to send it to the server
240240
// after a purchase
241-
_productDetailsToPurchase = productDetailsProcessed;
241+
_lastProductDetailsToPurchase = productDetailsProcessed;
242242

243243
try {
244244
// try to buy it
@@ -465,22 +465,19 @@ class LinkFivePurchasesImpl extends DefaultPurchaseHandler implements CallbackIn
465465
super.isPendingPurchase = false;
466466
break;
467467
case PurchaseStatus.purchased:
468-
final productDetails = _productDetailsToPurchase;
469-
_productDetailsToPurchase = null;
470-
471468
// handle ios Purchase
472469
if (Platform.isIOS) {
473470
// handle the ios purchase request to LinkFive
474471
await _handlePurchaseApple(
475472
appstorePurchaseDetails: purchaseDetails as AppStorePurchaseDetails,
476-
productDetailsToPurchase: productDetails,
473+
productDetailsToPurchase: _lastProductDetailsToPurchase,
477474
);
478475
}
479476
// Handle google play purchase
480477
if (purchaseDetails is GooglePlayPurchaseDetails) {
481478
await _handlePurchaseGoogle(
482479
purchaseDetails: purchaseDetails,
483-
productDetails: productDetails as GooglePlayProductDetails,
480+
productDetails: _lastProductDetailsToPurchase as GooglePlayProductDetails,
484481
);
485482
}
486483

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import 'dart:convert';
2+
3+
import 'package:http/http.dart';
4+
import 'package:http/io_client.dart';
5+
6+
abstract class LinkFiveHttpClient {
7+
Future<Response> post(Uri uri, {Map<String, String>? headers, Map<String, dynamic>? body});
8+
9+
Future<Response> get(Uri url, {Map<String, String>? headers});
10+
11+
Future<Response> put(Uri url, {Map<String, String>? headers, Map<String, dynamic>? body, Encoding? encoding});
12+
}
13+
14+
class HttpClientFactory {
15+
final Client _client;
16+
17+
HttpClientFactory.basic() : _client = IOClient();
18+
19+
Client getClient() => _client;
20+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'dart:convert';
2+
3+
import 'package:http/http.dart' as http;
4+
import 'package:in_app_purchases_interface/in_app_purchases_interface.dart';
5+
import 'package:linkfive_purchases/src/client/http/http_client.dart';
6+
7+
/// HTTP Client with retry function
8+
class RetryHttpClient extends LinkFiveHttpClient {
9+
final http.Client httpClient;
10+
final int requestTimeoutSeconds;
11+
final int retryTimeoutMillis;
12+
final int retryTimes = 3;
13+
14+
RetryHttpClient(HttpClientFactory clientFactory)
15+
: httpClient = clientFactory.getClient(),
16+
requestTimeoutSeconds = 16,
17+
retryTimeoutMillis = 500;
18+
19+
RetryHttpClient.test(HttpClientFactory clientFactory)
20+
: httpClient = clientFactory.getClient(),
21+
requestTimeoutSeconds = 1,
22+
retryTimeoutMillis = 0;
23+
24+
@override
25+
Future<http.Response> post(Uri uri, {Map<String, String>? headers, Map<String, dynamic>? body}) async {
26+
for (int retryCount = 0; retryCount < retryTimes; retryCount++) {
27+
try {
28+
final response = await httpClient
29+
.post(uri, body: jsonEncode(body), headers: headers)
30+
.timeout(Duration(seconds: requestTimeoutSeconds));
31+
if(response.statusCode == 429){
32+
throw Exception("Too many requests");
33+
}
34+
return response;
35+
} catch (e) {
36+
if (retryCount >= retryTimes - 1) {
37+
LinkFiveLogger.e("Retried $retryCount but still Request Error: ${e.toString()}");
38+
rethrow;
39+
}
40+
LinkFiveLogger.e("Purchase Request Error: ${e.toString()}");
41+
LinkFiveLogger.e("Try Again with same request");
42+
await Future.delayed(Duration(milliseconds: retryTimeoutMillis));
43+
}
44+
}
45+
throw Exception("Could not do any request");
46+
}
47+
48+
@override
49+
Future<http.Response> get(Uri url, {Map<String, String>? headers}) {
50+
return httpClient.get(url, headers: headers);
51+
}
52+
53+
@override
54+
Future<http.Response> put(Uri url, {Map<String, String>? headers, Object? body, Encoding? encoding}) {
55+
return httpClient.put(url, headers: headers, body: body, encoding: encoding);
56+
}
57+
}

packages/linkfive_purchases/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dev_dependencies:
2828
test: ^1.25.1
2929
flutter_lints: ^3.0.1
3030
mockito: ^5.4.4
31+
mocktail: ^1.0.3
3132
build_runner: ^2.4.8
3233
json_serializable: ^6.7.1
3334

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import 'dart:async';
2+
3+
import 'package:http/src/response.dart';
4+
import 'package:linkfive_purchases/src/client/http/retry_http_client.dart';
5+
import 'package:mocktail/mocktail.dart';
6+
import 'package:test/test.dart';
7+
8+
import '../mocks.dart';
9+
10+
void main() {
11+
final uri = Uri.parse("linkfive.io");
12+
13+
setUpAll(() {
14+
registerFallbackValue(Uri());
15+
});
16+
17+
test('instant 200', () async {
18+
final mockClient = MockClient();
19+
final retryClient = RetryHttpClient.test(MockHttpClientFactory()..withMockClient(mockClient));
20+
21+
when(() => mockClient.post(any(), body: any(named: "body"))).thenAnswer((_) async => Response("", 200));
22+
23+
final Response response = await retryClient.post(uri);
24+
25+
expect(response.statusCode, 200);
26+
});
27+
28+
test('always Exception', () async {
29+
final mockClient = MockClient();
30+
final retryClient = RetryHttpClient.test(MockHttpClientFactory()..withMockClient(mockClient));
31+
32+
when(() => mockClient.post(any(), body: any(named: "body"))).thenThrow(Error());
33+
34+
expect(retryClient.post(uri), throwsA(isA<Error>()));
35+
});
36+
37+
test('retry after 1', () async {
38+
final mockClient = MockClient();
39+
final retryClient = RetryHttpClient.test(MockHttpClientFactory()..withMockClient(mockClient));
40+
int requestCounter = 0;
41+
when(() => mockClient.post(any(), body: any(named: "body"))).thenAnswer((_) async {
42+
if (requestCounter++ < 1) {
43+
throw Exception("");
44+
}
45+
return Response("", 200);
46+
});
47+
48+
final Response response = await retryClient.post(uri);
49+
50+
expect(response.statusCode, 200);
51+
});
52+
53+
test('retry max RetryTimes + 0', () async {
54+
final mockClient = MockClient();
55+
final retryClient = RetryHttpClient.test(MockHttpClientFactory()..withMockClient(mockClient));
56+
int requestCounter = 0;
57+
when(() => mockClient.post(any(), body: any(named: "body"))).thenAnswer((_) async {
58+
if (requestCounter++ < retryClient.retryTimes - 1) {
59+
throw Error();
60+
}
61+
return Response("", 200);
62+
});
63+
64+
final Response response = await retryClient.post(uri);
65+
66+
expect(response.statusCode, 200);
67+
});
68+
69+
test('retry max RetryTimes + 1 error', () async {
70+
final mockClient = MockClient();
71+
final retryClient = RetryHttpClient.test(MockHttpClientFactory()..withMockClient(mockClient));
72+
int requestCounter = 0;
73+
when(() => mockClient.post(any(), body: any(named: "body"))).thenAnswer((_) async {
74+
if (requestCounter++ < retryClient.retryTimes) {
75+
throw Error();
76+
}
77+
return Response("", 200);
78+
});
79+
expect(retryClient.post(uri), throwsA(isA<Error>()));
80+
});
81+
82+
test('test timeout', () async {
83+
final mockClient = MockClient();
84+
final retryClient = RetryHttpClient.test(MockHttpClientFactory()..withMockClient(mockClient));
85+
when(() => mockClient.post(any(), body: any(named: "body"))).thenAnswer((_) async {
86+
await Future.delayed(const Duration(seconds: 15));
87+
return Response("", 200);
88+
});
89+
expect(retryClient.post(uri), throwsA(isA<TimeoutException>()));
90+
});
91+
92+
test('test error too many requests', () async {
93+
final mockClient = MockClient();
94+
final retryClient = RetryHttpClient.test(MockHttpClientFactory()..withMockClient(mockClient));
95+
when(() => mockClient.post(any(), body: any(named: "body"))).thenAnswer((_) async {
96+
return Response("", 429);
97+
});
98+
expect(retryClient.post(uri), throwsA(isA<Exception>()));
99+
});
100+
101+
test('test success with -1 too many requests', () async {
102+
final mockClient = MockClient();
103+
final retryClient = RetryHttpClient.test(MockHttpClientFactory()..withMockClient(mockClient));
104+
int requestCounter = 0;
105+
when(() => mockClient.post(any(), body: any(named: "body"))).thenAnswer((_) async {
106+
if (requestCounter++ < retryClient.retryTimes - 1) {
107+
return Response("", 429);
108+
}
109+
return Response("", 200);
110+
});
111+
112+
final response = await retryClient.post(uri);
113+
expect(response.statusCode, 200);
114+
});
115+
}

0 commit comments

Comments
 (0)