13 - State Management (Health Module)
- 1. Cubit map
- 2. HealthCubit (core)
- 3. Models
- 4. Error envelope handling (503 is data, not failure)
- 5. Cross-cutting
Per-screen Cubit/Bloc design on top of 00-shared/06 conventions (stack:
flutter_bloc+get_it; server state via dio repository;LoadState= Initial/Loading/Success/Error(ApiException)). Screens(proposed). Backend truth: single stateless endpoint (health.controller.ts:27-37) - all freshness logic lives client-side.
1. Cubit map
| Cubit | Screen (05) | Data |
|---|---|---|
HealthCubit | 1 (App Health) | HealthSnapshot (per-dependency status + extras + freshness), poll timer, retry |
| (none for S2) | 2 (Incident sheet) | renders from HealthCubit cached payload - no own cubit |
Repository (HealthRepository in features/health/data/) is the only layer
touching HTTP; it maps both the 200 envelope and the 503 error envelope to
models and throws ApiException(status, message) (00-shared/06 §2-3).
2. HealthCubit (core)
stateDiagram-v2
[*] --> Initial
Initial --> Loading: fetch()
Loading --> AllUp(snapshot): 200 envelope, status ok
Loading --> PartialDown(snapshot): 503 error, <5 down
Loading --> AllDown(snapshot): 503 error, 5 down
Loading --> Error(offline): network/timeout (LoadState.Error)
AllUp/PartialDown/AllDown --> Polling: timer starts (30 s)
Polling --> AllUp/PartialDown/AllDown: silent refetch (timer reset)
Polling --> RateLimited: 429 → pause timer, countdown
RateLimited --> Polling: 60 s elapsed → refetch
Polling --> Error: network error (keep last snapshot + stale flag)
Error --> Polling: retry()/connectivity regained
- Polling:
Timer.periodic(30 s)while foregrounded; canceled on background (AppLifecycleState.paused) and restarted with an immediatefetch()on resume (10I7). 30 s chosen to stay far under the 30 req/min public cap shared with LBs (12§5,rate-limit.constants.ts:4). - Retry: manual
retry()= single immediate fetch; on success resets the poll timer (skip next tick). In-flight guard: one request at a time (08§1).
3. Models
class HealthSnapshot {
final DateTime checkedAt; // client clock (server sends none - 12 §6)
final Map<String, DependencyStatus> deps; // mongodb, redis, storage, bullmq
final bool reachable; // HTTP 200 received
final bool rateLimited; // 429 seen
final List<int>? pendingJobs; // bullmq only: [emails, in-app, webhook-deliver, dlq]
final String? ping; // redis only: 'PONG'
}
enum DependencyStatus { up, down, stale } // stale = last data older than 60 s
- Source mapping:
data.info.<key>.status→up;data.error.<key>→down(keys present ininfostayupon partial failure,health.controller.spec.ts:29-35); extras fromdata.info.redis.ping(redis-health.indicator.ts:16) anddata.info.bullmq.pendingJobs(bullmq-health.indicator.ts:30). - API card is
reachable(request reached server) - no explicit key.
4. Error envelope handling (503 is data, not failure)
HealthRepository treats 503 as a successful fetch of a down state, not an
exception: it maps the error envelope (http-exception.filter.ts:73-81) into a
HealthSnapshot with all keys down + reachable: false. Only network
errors/timeouts/5xx-without-health-shape become LoadState.Error. This is the
single most important client decision in the module (09 §2, 14 QA-1).
5. Cross-cutting
- No cache layer: the screen is its own freshest state; no persistence - restarting the app refetches. Stateless endpoint = nothing to warm.
- No domain events: the client does not consume any event stream for
health (
event-queue-maphas no health events); polling is the only source. - Rate budget: poll (30 s) + manual retry must respect the shared 30
req/min public cap; on 429 the cubit pauses polling 60 s (
12§5). - Permission gating: screen gating is
(proposed)- nohealth.*permission exists (permissions.constants.ts:1-97); when added, gateHealthCubitstart on it. - Planned: liveness/readiness split polling per endpoint when
/live+/readyland(planned)-docs/user-flows/END_TO_END_USER_FLOWS.md:738-740.