Add simple retry logic to the base provider's fetch method

This should take care of simple transient errors, or other network hiccups
that might happen on the user's device.
This commit is contained in:
Roland Geider
2026-01-14 13:59:41 +01:00
parent 5844a370d3
commit 574ef3d0b5
16 changed files with 536 additions and 39 deletions

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (c) 2026 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -16,19 +16,27 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import 'package:logging/logging.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/providers/auth.dart';
import 'package:wger/providers/helpers.dart';
/// initial delay for fetch retries, in milliseconds
const FETCH_INITIAL_DELAY = 250;
/// Base provider class.
///
/// Provides a couple of comfort functions so we avoid a bit of boilerplate.
class WgerBaseProvider {
final _logger = Logger('WgerBaseProvider');
AuthProvider auth;
late http.Client client;
@@ -56,21 +64,53 @@ class WgerBaseProvider {
}
/// Fetch and retrieve the overview list of objects, returns the JSON parsed response
Future<dynamic> fetch(Uri uri) async {
// Future<Map<String, dynamic> | List<dynamic>> fetch(Uri uri) async {
// Send the request
final response = await client.get(
uri,
headers: getDefaultHeaders(includeAuth: true),
);
/// with a simple retry mechanism for transient errors.
Future<dynamic> fetch(
Uri uri, {
int maxRetries = 3,
Duration initialDelay = const Duration(milliseconds: 250),
}) async {
int attempt = 0;
final random = math.Random();
// Something wrong with our request
if (response.statusCode >= 400) {
throw WgerHttpException(response);
Future<void> wait(String reason) async {
final backoff = (initialDelay.inMilliseconds * math.pow(2, attempt - 1)).toInt();
final jitter = random.nextInt((backoff * 0.25).toInt() + 1); // up to 25% jitter
final delay = backoff + jitter;
_logger.info('Retrying fetch for $uri, attempt $attempt (${delay}ms), reason: $reason');
await Future.delayed(Duration(milliseconds: delay));
}
// Process the response
return json.decode(utf8.decode(response.bodyBytes)) as dynamic;
while (true) {
try {
final response = await client
.get(uri, headers: getDefaultHeaders(includeAuth: true))
.timeout(const Duration(seconds: 5));
if (response.statusCode >= 400) {
// Retry on server errors (5xx); e.g. 502 might be transient
if (response.statusCode >= 500 && attempt < maxRetries) {
attempt++;
await wait('status code ${response.statusCode}');
continue;
}
throw WgerHttpException(response);
}
return json.decode(utf8.decode(response.bodyBytes)) as dynamic;
} catch (e) {
final isRetryable =
e is SocketException || e is http.ClientException || e is TimeoutException;
if (isRetryable && attempt < maxRetries) {
attempt++;
await wait(e.toString());
continue;
}
rethrow;
}
}
}
/// Fetch and retrieve the overview list of objects, returns the JSON parsed response

View File

@@ -1100,9 +1100,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider {
as Uri);
@override
_i18.Future<dynamic> fetch(Uri? uri) =>
_i18.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i18.Future<dynamic>.value(),
)
as _i18.Future<dynamic>);

View File

@@ -402,9 +402,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider {
as Uri);
@override
_i14.Future<dynamic> fetch(Uri? uri) =>
_i14.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i14.Future<dynamic>.value(),
)
as _i14.Future<dynamic>);

View File

@@ -175,9 +175,17 @@ class MockGalleryProvider extends _i1.Mock implements _i4.GalleryProvider {
as Uri);
@override
_i6.Future<dynamic> fetch(Uri? uri) =>
_i6.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i6.Future<dynamic>.value(),
)
as _i6.Future<dynamic>);

View File

@@ -175,9 +175,17 @@ class MockGalleryProvider extends _i1.Mock implements _i4.GalleryProvider {
as Uri);
@override
_i6.Future<dynamic> fetch(Uri? uri) =>
_i6.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i6.Future<dynamic>.value(),
)
as _i6.Future<dynamic>);

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -122,9 +122,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -357,9 +357,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i8.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -0,0 +1,135 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2026 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/providers/base_provider.dart';
import '../utils.dart';
import 'base_provider.mocks.dart';
@GenerateMocks([Client])
void main() {
final Uri testUri = Uri(scheme: 'https', host: 'localhost', path: 'api/v2/test/');
test('Retry on SocketException then succeeds', () async {
// Arrange
final mockClient = MockClient();
var callCount = 0;
when(mockClient.get(testUri, headers: anyNamed('headers'))).thenAnswer((_) {
if (callCount == 0) {
callCount++;
return Future.error(const SocketException('conn fail'));
}
return Future.value(Response('{"ok": true}', 200));
});
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
final result = await provider.fetch(testUri, initialDelay: const Duration(milliseconds: 1));
// Assert
expect(result, isA<Map>());
expect(result['ok'], isTrue);
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(2);
});
test('Retry on 5xx then succeeds', () async {
// Arrange
final mockClient = MockClient();
var callCount = 0;
when(mockClient.get(testUri, headers: anyNamed('headers'))).thenAnswer((_) {
if (callCount == 0) {
callCount++;
return Future.value(Response('{"msg":"error"}', 502));
}
return Future.value(Response('{"ok": true}', 200));
});
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
final result = await provider.fetch(testUri, initialDelay: const Duration(milliseconds: 1));
// Assert
expect(result, isA<Map>());
expect(result['ok'], isTrue);
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(2);
});
test('Do not retry on 4xx client error', () async {
// Arrange
final mockClient = MockClient();
when(
mockClient.get(testUri, headers: anyNamed('headers')),
).thenAnswer((_) => Future.value(Response('{"error":"bad"}', 400)));
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
// Assert
await expectLater(
provider.fetch(testUri, initialDelay: const Duration(milliseconds: 1)),
throwsA(isA<WgerHttpException>()),
);
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(1);
});
test('Exceed max retries and rethrow after retries', () async {
// Arrange
final mockClient = MockClient();
when(
mockClient.get(testUri, headers: anyNamed('headers')),
).thenAnswer((_) => Future.error(ClientException('conn fail')));
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
dynamic caught;
try {
await provider.fetch(testUri, initialDelay: const Duration(milliseconds: 1));
} catch (e) {
caught = e;
}
// Assert
expect(caught, isA<ClientException>());
// initial try + 3 retries = 4 calls
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(4);
});
test('Request succeeds without retries', () async {
// Arrange
final mockClient = MockClient();
when(
mockClient.get(testUri, headers: anyNamed('headers')),
).thenAnswer((_) => Future.value(Response('{"ok": true}', 200)));
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
final result = await provider.fetch(testUri);
// Assert
expect(result, isA<Map>());
expect(result['ok'], isTrue);
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(1);
});
}

View File

@@ -0,0 +1,218 @@
// Mocks generated by Mockito 5.4.6 from annotations
// in wger/test/providers/base_provider.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i3;
import 'dart:convert' as _i4;
import 'dart:typed_data' as _i6;
import 'package:http/http.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
// ignore_for_file: deprecated_member_use_from_same_package
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: must_be_immutable
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response {
_FakeResponse_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation);
}
class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse {
_FakeStreamedResponse_1(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
/// A class which mocks [Client].
///
/// See the documentation for Mockito's code generation for more information.
class MockClient extends _i1.Mock implements _i2.Client {
MockClient() {
_i1.throwOnMissingStub(this);
}
@override
_i3.Future<_i2.Response> head(Uri? url, {Map<String, String>? headers}) =>
(super.noSuchMethod(
Invocation.method(#head, [url], {#headers: headers}),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(#head, [url], {#headers: headers}),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> get(Uri? url, {Map<String, String>? headers}) =>
(super.noSuchMethod(
Invocation.method(#get, [url], {#headers: headers}),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(#get, [url], {#headers: headers}),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> post(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#post,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(
#post,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> put(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#put,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(
#put,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> patch(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#patch,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(
#patch,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> delete(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#delete,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(
#delete,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<String> read(Uri? url, {Map<String, String>? headers}) =>
(super.noSuchMethod(
Invocation.method(#read, [url], {#headers: headers}),
returnValue: _i3.Future<String>.value(
_i5.dummyValue<String>(
this,
Invocation.method(#read, [url], {#headers: headers}),
),
),
)
as _i3.Future<String>);
@override
_i3.Future<_i6.Uint8List> readBytes(
Uri? url, {
Map<String, String>? headers,
}) =>
(super.noSuchMethod(
Invocation.method(#readBytes, [url], {#headers: headers}),
returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)),
)
as _i3.Future<_i6.Uint8List>);
@override
_i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) =>
(super.noSuchMethod(
Invocation.method(#send, [request]),
returnValue: _i3.Future<_i2.StreamedResponse>.value(
_FakeStreamedResponse_1(
this,
Invocation.method(#send, [request]),
),
),
)
as _i3.Future<_i2.StreamedResponse>);
@override
void close() => super.noSuchMethod(
Invocation.method(#close, []),
returnValueForMissingStub: null,
);
}

View File

@@ -201,9 +201,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i20.Future<dynamic> fetch(Uri? uri) =>
_i20.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i20.Future<dynamic>.value(),
)
as _i20.Future<dynamic>);

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -151,9 +151,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i11.Future<dynamic> fetch(Uri? uri) =>
_i11.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i11.Future<dynamic>.value(),
)
as _i11.Future<dynamic>);

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 500),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);