Skip to main content

Error Handling & Reconnection

:::info Status Implemented. Source: docs/user-device-mqtt-contract.md §3, §8, §8a, §9a, docs/transmitter-mqtt-contract.md §2, §4. :::

Reconnection

Both device types: exponential backoff 1s → 60s max, jittered. The audio uplink WebSocket uses the same posture.

flapping_detect on the broker bans a client for 5 minutes after 10 connects in a minute — jittered backoff means a healthy unit recovering from an outage never trips it; a unit stuck in a connect loop does, and at fleet scale that's indistinguishable from an attack. See Device Deployment — listener rate limits.

An MQTT disconnect must never stop audio. Both contracts require the audio pipeline and the MQTT client to run independently — see Events — MQTT down, audio still playing.

Close codeReason
4404room_id isn't a known room
4401Unknown device, wrong secret, or the device is not ACTIVE-and-bound

A 4401/4404 close is terminal for that connection attempt — not something to retry immediately in a tight loop. See Transmitter Audio Uplink.

Session semantics govern what "offline" means

TransmitterStream player
Clean sessiontrueclean_start=true, Session-Expiry-Interval=0
cmd while offlinedropped (no persistent session to queue in)dropped, deliberately
A play missed during an outagenever delivered latenever delivered late

The stream player contract states this most explicitly: a persistent session that queued commands was considered and rejected, because a queued play delivered late means the unit broadcasts a stale azaan at the wrong hour. Configuration survives disconnection through retained messages, not the session — a unit off for a month reconnects and gets current desired state in one message. The broker never holds undelivered azaan; a missed trigger is recorded not_confirmed, never replayed.

Idempotency and replay — duplicates are guaranteed, not a failure mode

QoS 1, retained messages, a backend outbox that retries, and residential links that drop mid-delivery together guarantee every command and config arrives more than once. The device must handle that; the backend cannot do it on the device's behalf.

  • Duplicate commands — keep a seen-set of the last 100 req_ids (or 1 hour, whichever is larger). Already seen → re-send the original ack, do not execute again. Executing deauthorize twice is harmless; play twice mid-azaan is audible; a redelivered unbind_mosque after a rebind would silently undo the rebind.
  • Expired commandsexpires_at passed → ack result:"expired" and do nothing. A play redelivered twenty minutes after Maghrib must not play.
  • Config versions are monotonic:
    incoming config_version > stored → apply, persist, ack
    incoming config_version == stored → ack, do not re-apply
    incoming config_version < stored → IGNORE, ack ← stale retained message
    The last rule matters most: on reconnect a device can receive a retained config older than what it holds (e.g. a rebind published while it was offline races a broker redelivery). Applying it would silently move the unit back to its previous mosque.

No trusted clock — time-sensitive commands before NTP sync

A device with no battery-backed RTC has a wrong clock after every power loss until NTP completes. In that window expires_at can't be evaluated, and a replayed old play would otherwise execute:

before NTP sync: discard time-sensitive commands → ack result:"expired",
data.reason:"clock_not_synced"; quiet_hours treated as disabled
after NTP sync: normal evaluation

Rejecting a real azaan in the seconds after a reboot is a smaller fault than playing a replayed one at the wrong hour. See Device Lifecycle — power-loss convergence for the full boot sequence this fits into.

Broker outage

What stops and what keeps working during a broker outage — and why a single-node broker is defensible as a first stage — is covered in Monitoring — what a broker outage costs. In short: scheduled azaan keeps playing from cached config until the entitlement lease (stream player only) expires; Bluetooth and app installs are unaffected; live play commands, status, and heartbeat stop until the broker recovers.

Crash-loop containment (stream player)

More than 5 reboots in 10 minutes → back off to a 5-minute retry interval before reconnecting, and publish CONFIG_ERROR with detail.reason:"crash_loop" once able. A fleet crash-looping after a bad config or OTA is otherwise indistinguishable from a reconnect storm and does the same damage to the broker.