Skip to content

Commit 47300e1

Browse files
authored
fix(system-health-check): add additional time providers and update URLs of existing providers (#2611)
* feat(system-health): add NTP and HTTP header time providers * test(system-health): add unit tests for system clock providers * chore(deps): add ntp package dependency and reorder imports * refactor(system-health): rename events & move logging init to bootstrap * fix(system-health): add binance time provider for web CORS rules block accessing the `date` header on web from other domains unless it's been explicitly allowed. * refactor: change method return type and implement ai review suggestions * fix(system-health): show banner if clock invalid even if peers >= 2 previous check was a regression that wouldn't show the banner for incorrect clock if there were still peers connected * refactor: simplify datetime parsing and revert exception regression * refactor: remove shared http client to avoid issues with multiple closes
1 parent 5a6ad28 commit 47300e1

26 files changed

+1019
-131
lines changed

lib/bloc/app_bloc_root.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,8 @@ class AppBlocRoot extends StatelessWidget {
284284
),
285285
),
286286
BlocProvider<SystemHealthBloc>(
287-
create: (_) => SystemHealthBloc(SystemClockRepository(), mm2Api),
287+
create: (_) => SystemHealthBloc(SystemClockRepository(), mm2Api)
288+
..add(SystemHealthPeriodicCheckStarted()),
288289
),
289290
BlocProvider<TrezorInitBloc>(
290291
create: (context) => TrezorInitBloc(
@@ -300,7 +301,8 @@ class AppBlocRoot extends StatelessWidget {
300301
),
301302
),
302303
BlocProvider<FaucetBloc>(
303-
create: (context) => FaucetBloc(kdfSdk: context.read<KomodoDefiSdk>()),
304+
create: (context) =>
305+
FaucetBloc(kdfSdk: context.read<KomodoDefiSdk>()),
304306
)
305307
],
306308
child: _MyAppView(),
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import 'dart:async' show TimeoutException;
2+
import 'dart:convert';
3+
import 'dart:io';
4+
5+
import 'package:http/http.dart' as http;
6+
import 'package:logging/logging.dart';
7+
import 'package:web_dex/bloc/system_health/providers/time_provider.dart';
8+
9+
/// A time provider that fetches time from the Binance API
10+
class BinanceTimeProvider extends TimeProvider {
11+
BinanceTimeProvider({
12+
this.url = 'https://api.binance.com/api/v3/time',
13+
http.Client? httpClient,
14+
this.timeout = const Duration(seconds: 2),
15+
this.maxRetries = 3,
16+
Logger? logger,
17+
}) : _httpClient = httpClient ?? http.Client(),
18+
_logger = logger ?? Logger('BinanceTimeProvider');
19+
20+
/// The URL of the Binance time API
21+
final String url;
22+
23+
/// Timeout for HTTP requests
24+
final Duration timeout;
25+
26+
/// Maximum retries
27+
final int maxRetries;
28+
29+
/// Logger instance
30+
final Logger _logger;
31+
32+
/// HTTP client for making requests
33+
final http.Client _httpClient;
34+
35+
@override
36+
String get name => 'Binance';
37+
38+
@override
39+
Future<DateTime> getCurrentUtcTime() async {
40+
int retries = 0;
41+
42+
while (retries < maxRetries) {
43+
try {
44+
final serverTime = await _fetchServerTime();
45+
_logger.fine('Successfully retrieved time from Binance API');
46+
return serverTime;
47+
} on SocketException catch (e, s) {
48+
_logger.warning('Socket error with Binance API', e, s);
49+
} on TimeoutException catch (e, s) {
50+
_logger.warning('Timeout with Binance API', e, s);
51+
} on FormatException catch (e, s) {
52+
_logger.severe('Failed to parse response from Binance API', e, s);
53+
} on Exception catch (e, s) {
54+
_logger.severe('Error fetching time from Binance API', e, s);
55+
}
56+
retries++;
57+
58+
// Calculate exponential backoff: 100ms, 200ms, 400ms, 800ms...
59+
if (retries < maxRetries) {
60+
final delayDuration = Duration(milliseconds: 100 * (1 << retries));
61+
await Future<void>.delayed(delayDuration);
62+
}
63+
}
64+
65+
_logger.severe(
66+
'Failed to get time from Binance API after $maxRetries retries',
67+
);
68+
throw TimeoutException(
69+
'Failed to get time from Binance API after $maxRetries retries',
70+
);
71+
}
72+
73+
/// Fetches server time from the Binance API
74+
Future<DateTime> _fetchServerTime() async {
75+
final response = await _httpClient.get(Uri.parse(url)).timeout(timeout);
76+
77+
if (response.statusCode != 200) {
78+
_logger.warning('HTTP error from $url: ${response.statusCode}');
79+
throw HttpException(
80+
'HTTP error from $url: ${response.statusCode}',
81+
uri: Uri.parse(url),
82+
);
83+
}
84+
85+
final jsonData = jsonDecode(response.body) as Map<String, dynamic>;
86+
final serverTime = jsonData['serverTime'] as int?;
87+
88+
if (serverTime == null) {
89+
throw const FormatException(
90+
'No serverTime field in Binance API response',
91+
);
92+
}
93+
94+
return DateTime.fromMillisecondsSinceEpoch(serverTime, isUtc: true);
95+
}
96+
97+
@override
98+
void dispose() {
99+
_httpClient.close();
100+
}
101+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import 'dart:async' show TimeoutException;
2+
import 'dart:io';
3+
import 'dart:math' show Random;
4+
5+
import 'package:http/http.dart' as http;
6+
import 'package:logging/logging.dart';
7+
import 'package:web_dex/bloc/system_health/providers/time_provider.dart';
8+
9+
/// A time provider that fetches time from server 'Date' headers via HEAD requests
10+
class HttpHeadTimeProvider extends TimeProvider {
11+
HttpHeadTimeProvider({
12+
this.servers = const [
13+
'https://alibaba.com/',
14+
'https://google.com/',
15+
'https://cloudflare.com/',
16+
'https://microsoft.com/',
17+
'https://github.com/',
18+
],
19+
http.Client? httpClient,
20+
this.timeout = const Duration(seconds: 2),
21+
this.maxRetries = 3,
22+
Logger? logger,
23+
}) : _httpClient = httpClient ?? http.Client(),
24+
_logger = logger ?? Logger('HttpHeadTimeProvider');
25+
26+
/// The name of the provider (for logging and identification)
27+
final Logger _logger;
28+
29+
/// List of servers to query via HEAD requests
30+
final List<String> servers;
31+
32+
/// Timeout for HTTP requests
33+
final Duration timeout;
34+
35+
/// Maximum retries per server
36+
final int maxRetries;
37+
38+
final http.Client _httpClient;
39+
40+
@override
41+
String get name => 'HttpHead';
42+
43+
@override
44+
Future<DateTime> getCurrentUtcTime() async {
45+
// Randomize the order of servers to avoid overloading any single server
46+
// and to provide a more even distribution of requests.
47+
// This also avoid a single server being a single point of failure.
48+
final shuffledServers = List<String>.from(servers)..shuffle(Random());
49+
_logger.fine('Randomized server order for time retrieval');
50+
51+
for (final serverUrl in shuffledServers) {
52+
int retries = 0;
53+
54+
while (retries < maxRetries) {
55+
try {
56+
final serverTime = await _fetchServerTime(serverUrl);
57+
_logger.fine('Successfully retrieved time from $serverUrl');
58+
return serverTime;
59+
} on SocketException catch (e, s) {
60+
_logger.warning('Socket error with $serverUrl', e, s);
61+
} on TimeoutException catch (e, s) {
62+
_logger.warning('Timeout with $serverUrl', e, s);
63+
} on HttpException catch (e, s) {
64+
_logger.warning('HTTP error with $serverUrl', e, s);
65+
} on FormatException catch (e, s) {
66+
_logger.warning('Date header parse error with $serverUrl', e, s);
67+
}
68+
retries++;
69+
}
70+
}
71+
72+
_logger
73+
.severe('Failed to get time from any server after $maxRetries retries');
74+
throw TimeoutException(
75+
'Failed to get time from any server after $maxRetries retries',
76+
);
77+
}
78+
79+
/// Fetches server time from the 'date' header of an HTTP HEAD response
80+
Future<DateTime> _fetchServerTime(String url) async {
81+
final response = await _httpClient.head(Uri.parse(url)).timeout(timeout);
82+
83+
// Treat any successful or redirect status as acceptable.
84+
if (response.statusCode < 200 || response.statusCode >= 400) {
85+
_logger.warning('HTTP error from $url: ${response.statusCode}');
86+
throw HttpException(
87+
'HTTP error from $url: ${response.statusCode}',
88+
uri: Uri.parse(url),
89+
);
90+
}
91+
92+
final dateHeader = response.headers['date'];
93+
if (dateHeader == null) {
94+
_logger.warning('No Date header in response from $url');
95+
throw FormatException('No Date header in response from $url');
96+
}
97+
98+
final parsed = HttpDate.parse(dateHeader);
99+
return parsed.toUtc();
100+
}
101+
102+
/// Disposes the HTTP client when done
103+
@override
104+
void dispose() {
105+
_httpClient.close();
106+
}
107+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:http/http.dart' as http;
5+
import 'package:logging/logging.dart';
6+
import 'package:web_dex/bloc/system_health/providers/time_provider.dart';
7+
8+
/// A time provider that fetches time from an HTTP API
9+
class HttpTimeProvider extends TimeProvider {
10+
HttpTimeProvider({
11+
required this.url,
12+
required this.timeFieldPath,
13+
required this.timeFormat,
14+
required String providerName,
15+
http.Client? httpClient,
16+
Duration? apiTimeout,
17+
Logger? logger,
18+
}) : _httpClient = httpClient ?? http.Client(),
19+
_apiTimeout = apiTimeout ?? const Duration(seconds: 2),
20+
name = providerName,
21+
_logger = logger ?? Logger(providerName);
22+
23+
/// The URL of the time API
24+
final String url;
25+
26+
/// The field path in the JSON response that contains the time.
27+
///
28+
/// Separate nested fields with dots (e.g., "time.current")
29+
final String timeFieldPath;
30+
31+
/// The format of the time string in the response
32+
final TimeFormat timeFormat;
33+
34+
/// The name of the provider (for logging and identification)
35+
@override
36+
final String name;
37+
38+
final Logger _logger;
39+
40+
final http.Client _httpClient;
41+
final Duration _apiTimeout;
42+
43+
@override
44+
Future<DateTime> getCurrentUtcTime() async {
45+
final response = await _httpClient.get(Uri.parse(url)).timeout(_apiTimeout);
46+
47+
if (response.statusCode != 200) {
48+
_logger.warning('API request failed with status ${response.statusCode}');
49+
throw HttpException(
50+
'API request failed with status ${response.statusCode}',
51+
uri: Uri.parse(url),
52+
);
53+
}
54+
55+
final dynamic decoded = json.decode(response.body);
56+
if (decoded is! Map<String, dynamic>) {
57+
_logger.warning(
58+
'Expected top-level JSON object, got ${decoded.runtimeType}',
59+
);
60+
throw const FormatException('Invalid JSON structure – object expected');
61+
}
62+
final Map<String, dynamic> jsonResponse = decoded;
63+
final parsedTime = await _parseTimeFromJson(jsonResponse);
64+
65+
return parsedTime;
66+
}
67+
68+
Future<DateTime> _parseTimeFromJson(Map<String, dynamic> jsonResponse) async {
69+
final fieldParts = timeFieldPath.split('.');
70+
dynamic value = jsonResponse;
71+
72+
for (final part in fieldParts) {
73+
if (value is! Map<String, dynamic>) {
74+
_logger.warning('JSON path error: expected Map at $part');
75+
throw FormatException('JSON path error: expected Map at $part');
76+
}
77+
value = value[part];
78+
if (value == null) {
79+
_logger.warning('JSON path error: null value at $part');
80+
throw FormatException('JSON path error: null value at $part');
81+
}
82+
}
83+
84+
final timeStr = value.toString();
85+
if (timeStr.isEmpty) {
86+
_logger.warning('Empty time string');
87+
throw const FormatException('Empty time string');
88+
}
89+
90+
return _parseDateTime(timeStr);
91+
}
92+
93+
DateTime _parseDateTime(String timeStr) {
94+
switch (timeFormat) {
95+
case TimeFormat.iso8601:
96+
return DateTime.parse(timeStr).toUtc();
97+
case TimeFormat.custom:
98+
throw const FormatException('Custom time format not supported');
99+
}
100+
}
101+
102+
@override
103+
void dispose() {
104+
_httpClient.close();
105+
}
106+
}
107+
108+
/// Enum representing the format of time returned by the API
109+
enum TimeFormat {
110+
/// ISO8601 format (e.g. "2023-05-07T12:34:56Z")
111+
iso8601,
112+
113+
/// Custom format that may require special parsing
114+
custom
115+
}

0 commit comments

Comments
 (0)