Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions lib/core/network/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,15 @@ class ApiClient {
_health?.success(tier, hosts[i], path);
return response;
} on DioException catch (e) {
_health?.failure(tier, hosts[i], path);
final retryable = _isRetryable(e);
// Only what the *host* is answerable for, which is what [_isRetryable]
// already separates. A 404 says the route or the resource is wrong, not
// that the region is sick: replaying an event older than the RTS
// retention 404s once a second, and charging those to the host parked
// `api-1` at `down` on the status screen for the rest of the session.
if (retryable) _health?.failure(tier, hosts[i], path);
final isLastHost = i == hosts.length - 1;
if (isLastHost || !_isRetryable(e)) rethrow;
if (isLastHost || !retryable) rethrow;
Log.warning(
'ApiClient: ${tier.name} ${hosts[i]} failed (${_describe(e)}); '
'failing over to ${hosts[i + 1]}',
Expand Down Expand Up @@ -239,9 +245,12 @@ class ApiClient {
_health?.success(tier, hosts[i], path);
return StreamedResponse(response.data!.stream, cancelToken.cancel);
} on DioException catch (e) {
_health?.failure(tier, hosts[i], path);
// Same split as [request]: a host is only charged for what it is
// answerable for.
final retryable = _isRetryable(e);
if (retryable) _health?.failure(tier, hosts[i], path);
final isLastHost = i == hosts.length - 1;
if (isLastHost || !_isRetryable(e)) rethrow;
if (isLastHost || !retryable) rethrow;
Log.warning(
'ApiClient: ${tier.name} stream ${hosts[i]} failed (${_describe(e)}); '
'failing over to ${hosts[i + 1]}',
Expand Down
21 changes: 17 additions & 4 deletions lib/core/network/api_exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,28 @@ import 'package:dpip/core/logging/log.dart';
/// every repository method is a one-liner and none can accidentally forget the
/// `try` or return `Ok` on failure — which for a safety feed would turn a dead
/// source into a false all-clear.
Future<Result<T>> guardResult<T>(Future<T> Function() body) async {
///
/// [shouldLog] is the narrow exception to the logging below, for a caller whose
/// failure is an expected shape rather than a fault — a replay polling past the
/// end of a feed's retention, say, where the 404 arrives once a second for the
/// whole session. It drops the log line only; the [Err] is returned either way,
/// so no caller can mistake a suppressed log for a success.
Future<Result<T>> guardResult<T>(
Future<T> Function() body, {
bool Function(Failure failure)? shouldLog,
}) async {
try {
return Ok(await body());
} catch (error, stackTrace) {
// Silent failures are the worst kind: the UI shows its error state and
// nothing else records WHY. One line per failure, here at the single
// choke point every repository passes through.
Log.handle(error, stackTrace, 'repository fetch/decode');
return Err(mapException(error));
// choke point every repository passes through — unless the caller has
// said this particular failure is expected (see [shouldLog]).
final failure = mapException(error);
if (shouldLog?.call(failure) ?? true) {
Log.handle(error, stackTrace, 'repository fetch/decode');
}
return Err(failure);
}
}

Expand Down
37 changes: 31 additions & 6 deletions lib/core/realtime/realtime_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ class RealtimeChannel<T> implements RealtimeChannelBase {
final status = _classify();
final changed =
status != _current.status ||
// A recovery is always worth telling: the state being replaced
// carries the reason the feed was empty, which the replay page
// shows even while the status word itself hasn't moved.
_current.lastFailure != null ||
!_source.sameData(value, _current.data);
_current = RealtimeState<T>(
status: status,
Expand All @@ -234,17 +238,38 @@ class RealtimeChannel<T> implements RealtimeChannelBase {
);
if (changed) _publish();
case Err(:final failure):
// "There is no data for that instant" is an answer, not a fault. It
// is still *recorded* — a replay page reads it to say "重播中" rather
// than "連線中斷" — but it is not counted and not logged: the poll
// runs at 1 Hz, so an old replay would otherwise file one crash
// report a second for its whole length.
//
// Freshness is untouched either way. The tick that preceded this
// fetch already aged the status (see [_onTick]), so an ignored
// failure still reaches stale and then offline on schedule — nothing
// here can hold a feed that is receiving nothing at `live`.
final ignorable = _source.isIgnorableFailure(failure);
final status = _classify();
final changed = status != _current.status;
// A change of failure *kind* is published even when the status word
// hasn't moved, because that is what the replay page switches on.
// Repeats of one kind are not: at 1 Hz that would be a rebuild a
// second for the length of an outage.
final changed =
status != _current.status ||
_current.lastFailure.runtimeType != failure.runtimeType;
_current = _current.copyWith(
status: status,
lastFailure: failure,
consecutiveFailures: _current.consecutiveFailures + 1,
);
Log.warning(
'[$_label] poll failed '
'(${_current.consecutiveFailures}×): ${failure.message}',
consecutiveFailures: ignorable
? _current.consecutiveFailures
: _current.consecutiveFailures + 1,
);
if (!ignorable) {
Log.warning(
'[$_label] poll failed '
'(${_current.consecutiveFailures}×): ${failure.message}',
);
}
if (changed) _publish();
}
} catch (error, stackTrace) {
Expand Down
17 changes: 17 additions & 0 deletions lib/core/realtime/realtime_source.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dpip/core/error/failure.dart';
import 'package:dpip/core/error/result.dart';

/// The transport + freshness-reference seam a [RealtimeChannel] polls.
Expand All @@ -23,6 +24,22 @@ abstract class RealtimeSource<T> {
/// whose default `==` is identity (e.g. `List`).
bool sameData(T? a, T? b) => identical(a, b) || a == b;

/// Whether a fetch failure means there is simply no data for the requested
/// point in time, rather than a fault worth counting. The channel still
/// records it as `lastFailure` — that is what a replay page reads to say the
/// instant has no snapshot instead of calling itself disconnected — but does
/// not count it toward `consecutiveFailures` and does not log it.
///
/// **A noise switch, not a liveness one.** Freshness is unaffected either
/// way: the channel ages its status from elapsed time alone, so a source that
/// ignores every failure still goes stale and then offline on schedule and
/// nothing here can present a dead feed as current.
///
/// Only a replay source has cause to override it — it polls a fixed instant
/// in the past, where "this far back is no longer retained" is an answer, not
/// a fault. For a live feed every failure is a real one; leave this alone.
bool isIgnorableFailure(Failure failure) => false;

/// Drops any transport the source is holding open while the app is in the
/// background, where nothing is watching the feed.
///
Expand Down
12 changes: 11 additions & 1 deletion lib/features/earthquake/data/rts_replay_source.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dpip/core/error/failure.dart';
import 'package:dpip/core/error/result.dart';
import 'package:dpip/core/network/api_exception.dart';
import 'package:dpip/core/realtime/realtime_source.dart';
Expand Down Expand Up @@ -25,7 +26,16 @@ class RtsReplaySource extends RealtimeSource<Rts> {
final seconds = clock.now().millisecondsSinceEpoch ~/ 1000;
final json = await _api.getRtsAt(seconds);
return Rts.fromJson(json as Map<String, dynamic>);
});
}, shouldLog: (failure) => !isIgnorableFailure(failure));

/// A 404 is the ordinary shape of an old replay, not a fault: RTS snapshots
/// are retained for far less time than the EEW history, so an event old
/// enough (0403, say) still has alerts to replay and no shaking left to draw.
/// Counted as a failure it would be one crash report and one log line **per
/// second** for the whole session — the poll runs at 1 Hz, which never trips
/// `Log`'s repeat suppression (8 within 5s).
@override
bool isIgnorableFailure(Failure failure) => failure is NotFoundFailure;

/// Null: freshness is "did the last poll succeed", not payload age — the
/// payload's own [Rts.time] is *intentionally* historical, so keying off it
Expand Down
18 changes: 12 additions & 6 deletions lib/features/earthquake/presentation/pages/report_replay_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1342,12 +1342,18 @@ class _ReplayStatusBar extends StatelessWidget {
final taipeiTime = AppTime.taipei(clock.now());
final timeText = _clockFormat.format(taipeiTime);

final (Color dot, String? statusWord) = switch (rts.status) {
RealtimeStatus.live => (Colors.green, null),
RealtimeStatus.stale => (Colors.amber, l10n.feedStale),
RealtimeStatus.offline => (Colors.red, l10n.feedOffline),
RealtimeStatus.connecting => (Colors.grey, l10n.feedConnecting),
};
// RTS snapshots age out of the server long before the EEW history does, so
// an old enough event replays as alerts over a map with no shaking on it.
// That feed is not broken and saying "連線中斷" reads as a broken app —
// the replay is running, there is just nothing recorded that far back.
final (Color dot, String? statusWord) = rts.isMissingHistory
? (Colors.orange, l10n.feedReplaying)
: switch (rts.status) {
RealtimeStatus.live => (Colors.green, null),
RealtimeStatus.stale => (Colors.amber, l10n.feedStale),
RealtimeStatus.offline => (Colors.red, l10n.feedOffline),
RealtimeStatus.connecting => (Colors.grey, l10n.feedConnecting),
};

final alertCount = eew.alerts.length;
final hasActiveEew = alertCount > 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dpip/core/error/failure.dart';
import 'package:dpip/core/realtime/realtime_notifier.dart';
import 'package:dpip/core/realtime/realtime_state.dart';
import 'package:dpip/features/earthquake/domain/rts.dart';
Expand Down Expand Up @@ -29,4 +30,12 @@ class RtsRealtimeController extends RealtimeNotifier<Rts> {

/// Whether the feed has aged past the freshness threshold.
bool get isStale => status == RealtimeStatus.stale;

/// Whether the last poll found no snapshot for the instant it asked for.
///
/// Only a replay reaches this: RTS snapshots are retained for far less time
/// than the EEW history, so an old enough event still has alerts to replay
/// and no shaking left to draw. The feed is not broken, so a UI must not call
/// it disconnected — there is simply nothing recorded that far back.
bool get isMissingHistory => state.lastFailure is NotFoundFailure;
}
4 changes: 4 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@
},
"moreSectionLinks": "Links",
"feedOffline": "Connection lost",
"feedReplaying": "Replaying",
"mapLayerStyleBd": "Dvorak BD",
"@mapLayerSatelliteB09": {
"description": "Himawari mid-level water-vapour channel (B09, 6.9 µm) layer name"
Expand Down Expand Up @@ -1136,6 +1137,9 @@
"@feedOffline": {
"description": "Banner/headline when a realtime feed has gone offline"
},
"@feedReplaying": {
"description": "Status word on the replay page when the replayed instant is older than the RTS retention window: the replay is running, the server just has no shaking snapshot that far back. Never 'offline' — the feed is not broken"
},
"reportFilterIntensityInfoModernTitle": "Current (from 2020)",
"@mapAppGoogleMaps": {
"description": "External map app choice: Google Maps"
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_fil.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "Kahapon",
"moreSectionLinks": "Mga Link",
"feedOffline": "Nawala ang koneksyon",
"feedReplaying": "Nire-replay",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "Display",
"rainInterval3d": "3 araw",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_id.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "Kemarin",
"moreSectionLinks": "Tautan",
"feedOffline": "Koneksi terputus",
"feedReplaying": "Memutar ulang",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "Tampilan",
"rainInterval3d": "3 hr",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_ja.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "昨日",
"moreSectionLinks": "関連リンク",
"feedOffline": "接続が切断されました",
"feedReplaying": "再生中",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "表示",
"rainInterval3d": "3日",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_ko.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "어제",
"moreSectionLinks": "링크",
"feedOffline": "연결이 끊어졌습니다",
"feedReplaying": "재생 중",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "표시",
"rainInterval3d": "3일",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_th.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "เมื่อวาน",
"moreSectionLinks": "ลิงก์ที่เกี่ยวข้อง",
"feedOffline": "การเชื่อมต่อขาดหาย",
"feedReplaying": "กำลังเล่นซ้ำ",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "การแสดงผล",
"rainInterval3d": "3 วัน",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_vi.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "Hôm qua",
"moreSectionLinks": "Liên kết",
"feedOffline": "Mất kết nối",
"feedReplaying": "Đang phát lại",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "Hiển thị",
"rainInterval3d": "3 ngày",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_yue.arb
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@
"reportListYesterday": "昨天",
"moreSectionLinks": "相關連結",
"feedOffline": "連接中斷",
"feedReplaying": "重播緊",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "顯示",
"rainInterval3d": "3 日",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "昨天",
"moreSectionLinks": "相關連結",
"feedOffline": "連線中斷",
"feedReplaying": "重播中",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "顯示",
"rainInterval3d": "3 日",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_zh_Hans.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "昨天",
"moreSectionLinks": "相关链接",
"feedOffline": "连接中断",
"feedReplaying": "重播中",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "显示",
"rainInterval3d": "3 日",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_zh_Hant_HK.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "昨天",
"moreSectionLinks": "相關連結",
"feedOffline": "連接中斷",
"feedReplaying": "重播中",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "顯示",
"rainInterval3d": "3 日",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_zh_TW.arb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"reportListYesterday": "昨天",
"moreSectionLinks": "相關連結",
"feedOffline": "連線中斷",
"feedReplaying": "重播中",
"mapLayerStyleBd": "Dvorak BD",
"moreSectionDisplay": "顯示",
"rainInterval3d": "3 日",
Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/gen/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,12 @@ abstract class AppLocalizations {
/// **'Connection lost'**
String get feedOffline;

/// Status word on the replay page when the replayed instant is older than the RTS retention window: the replay is running, the server just has no shaking snapshot that far back. Never 'offline' — the feed is not broken
///
/// In en, this message translates to:
/// **'Replaying'**
String get feedReplaying;

/// Colour-style option: Dvorak BD curve stepped grayscale
///
/// In en, this message translates to:
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/gen/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get feedOffline => 'Connection lost';

@override
String get feedReplaying => 'Replaying';

@override
String get mapLayerStyleBd => 'Dvorak BD';

Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/gen/app_localizations_fil.dart
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,9 @@ class AppLocalizationsFil extends AppLocalizations {
@override
String get feedOffline => 'Nawala ang koneksyon';

@override
String get feedReplaying => 'Nire-replay';

@override
String get mapLayerStyleBd => 'Dvorak BD';

Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/gen/app_localizations_id.dart
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get feedOffline => 'Koneksi terputus';

@override
String get feedReplaying => 'Memutar ulang';

@override
String get mapLayerStyleBd => 'Dvorak BD';

Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/gen/app_localizations_ja.dart
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get feedOffline => '接続が切断されました';

@override
String get feedReplaying => '再生中';

@override
String get mapLayerStyleBd => 'Dvorak BD';

Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/gen/app_localizations_ko.dart
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get feedOffline => '연결이 끊어졌습니다';

@override
String get feedReplaying => '재생 중';

@override
String get mapLayerStyleBd => 'Dvorak BD';

Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/gen/app_localizations_th.dart
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,9 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get feedOffline => 'การเชื่อมต่อขาดหาย';

@override
String get feedReplaying => 'กำลังเล่นซ้ำ';

@override
String get mapLayerStyleBd => 'Dvorak BD';

Expand Down
Loading
Loading