feat(extension-health): cache results, force refresh, treat lookup errors as transient

Cache health results after each check, add a force flag to bypass the cache when the download picker opens or a service is selected, guard stale concurrent results via a per-extension request serial, and treat DNS lookup failures as indeterminate/transient.
This commit is contained in:
zarzet
2026-06-28 06:05:48 +07:00
parent 21fe047e00
commit ce813bc216
4 changed files with 68 additions and 28 deletions
+17 -4
View File
@@ -59,6 +59,7 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
}
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
@@ -86,6 +87,20 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
extensionHealthCacheMu.Unlock()
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
return result
}
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return
}
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
ttl = extensionHealthUnknownCache
@@ -94,11 +109,9 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: now.Add(ttl),
expiresAt: time.Now().Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
@@ -271,7 +284,7 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
}
func isTransientExtensionHealthError(err error) bool {
return isTransientNetworkError(err)
return isTransientNetworkError(err) || isConnectivityFailure(err)
}
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
@@ -32,8 +32,8 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
t.Fatal("expected timeout health errors to be transient")
}
if isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
t.Fatal("expected non-timeout DNS errors to be non-transient")
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
t.Fatal("expected health transport lookup errors to be indeterminate")
}
if result := CheckExtensionHealth(nil); result.Status != "offline" {
+33 -19
View File
@@ -814,6 +814,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Completer<void>? _initializationCompleter;
final Map<String, DateTime> _healthExpiresAt = {};
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
final Map<String, int> _healthRequestSerial = {};
@override
ExtensionState build() {
@@ -825,6 +826,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
_appLifecycleListener = null;
_healthExpiresAt.clear();
_healthInFlight.clear();
_healthRequestSerial.clear();
});
return const ExtensionState();
}
@@ -954,15 +956,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
void _scheduleExtensionHealthRefresh(List<Extension> extensions) {
void _scheduleExtensionHealthRefresh(
List<Extension> extensions, {
bool force = false,
}) {
for (final ext in extensions) {
if (!ext.enabled || !ext.hasServiceHealth) continue;
unawaited(checkExtensionHealth(ext.id));
unawaited(checkExtensionHealth(ext.id, force: force));
}
}
void refreshEnabledExtensionHealth() {
_scheduleExtensionHealthRefresh(state.extensions);
void refreshEnabledExtensionHealth({bool force = false}) {
_scheduleExtensionHealthRefresh(state.extensions, force: force);
}
Future<ExtensionHealthStatus?> checkExtensionHealth(
@@ -990,17 +995,22 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return inFlight;
}
final requestSerial = (_healthRequestSerial[extensionId] ?? 0) + 1;
_healthRequestSerial[extensionId] = requestSerial;
final future = () async {
try {
final result = await PlatformBridge.checkExtensionHealth(extensionId);
final status = ExtensionHealthStatus.fromJson(result);
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
if (_healthRequestSerial[extensionId] == requestSerial) {
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
}
return status;
} catch (e) {
_log.w('Failed to check extension health for $extensionId: $e');
@@ -1010,16 +1020,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
checkedAt: DateTime.now(),
checks: const [],
);
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
const Duration(seconds: 20),
);
state = state.copyWith(healthStatuses: updated);
if (_healthRequestSerial[extensionId] == requestSerial) {
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
const Duration(seconds: 20),
);
state = state.copyWith(healthStatuses: updated);
}
return status;
} finally {
_healthInFlight.remove(extensionId);
if (_healthRequestSerial[extensionId] == requestSerial) {
_healthInFlight.remove(extensionId);
}
}
}();
+16 -3
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -74,7 +75,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
ref
.read(extensionProvider.notifier)
.refreshEnabledExtensionHealth(force: true);
});
final downloadExtensions = _downloadExtensions();
final recommended = widget.recommendedService;
@@ -102,6 +105,17 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return const [];
}
void _selectService(Extension extension) {
setState(() => _selectedService = extension.id);
if (extension.hasServiceHealth) {
unawaited(
ref
.read(extensionProvider.notifier)
.checkExtensionHealth(extension.id, force: true),
);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -166,8 +180,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
? extensionState.healthStatuses[ext.id]?.status
: null,
isSelected: _selectedService == ext.id,
onTap: () =>
setState(() => _selectedService = ext.id),
onTap: () => _selectService(ext),
iconPath: ext.iconPath,
),
],