sint_sentinel
Circuit Breaker, Rate Limiter, Logger & Snapshot Auditor for Flutter.
Born from a real incident where a single Flutter widget generated 165,000 Firestore reads in 4 hours due to a StreamBuilder inside build(). That bug cost real money and could have bankrupted the project. sint_sentinel makes sure it never happens again.
// Before (vulnerable): one infinite loop = unlimited billing
FirebaseFirestore.instance.collection('users').get();
// After (protected): one infinite loop = 3,000 reads max, then auto-block
SintSentinel.guard(() => FirebaseFirestore.instance.collection('users').get());
Why sint_sentinel?
Most backend circuit breakers protect servers. sint_sentinel protects the client — your Flutter app, your Firebase budget, and your users' experience.
| Threat | Without Sentinel | With Sentinel |
|---|---|---|
StreamBuilder in build() |
1.1M reads/day (real incident) | 3,000 reads, then auto-block |
Infinite setState loop |
UI frozen, app killed by OS | Loop throttled in <1 second |
| Runaway polling timer | Thousands of API calls/min | 60/min cap, circuit trips |
| Developer pushes bad code | Unlimited cost until hotfix | Instant local protection |
Features
- Application Logger — Centralized
Loggeraccessible everywhere viaSintSentinel.logger. Supports all log levels:d(),i(),w(),e(),t(),f(). Re-exportsLogger,Level,PrettyPrinterso consumers need only one import. - Circuit Breaker — Three-state machine (CLOSED / OPEN / HALF_OPEN) that blocks traffic when quotas are exceeded or failures accumulate.
- Traffic Watchdog — Sliding 60-second rate limiter + daily read/write quotas. Catches runaway Firestore listeners, accidental polling, bot abuse.
- State Watchdog — Monitors widget rebuild frequency per widget ID. Detects and throttles infinite
setStateloops before they freeze the UI. - Rebuild Monitor — Drop-in
Statemixin with three-tier response: silently drop excess rebuilds, warn in console, trip circuit if sustained. - Snapshot Auditor — Persistent history of circuit state snapshots. Auto-captured on every trip. Queryable by consumers (AI, analytics, debugging).
- Maintenance Page — Full-screen UI shown automatically when the circuit trips. User can retry; circuit recovers with exponential backoff.
- Hive Persistence — Everything survives app restarts: circuit state, counters, traffic history, snapshot ring buffer.
- Zero Config — Works out of the box with
SentinelConfig.production(). Three presets for dev/prod/strict.
Quick Start
1. Install
dependencies:
sint_sentinel: ^1.1.0
2. Wrap your app
import 'package:sint_sentinel/sint_sentinel.dart';
void main() {
runApp(
SentinelApp(
config: SentinelConfig.production(),
child: MyApp(),
),
);
}
3. Guard your calls
// Read operation (counts against per-minute rate + daily read quota)
final doc = await SintSentinel.guard(
() => FirebaseFirestore.instance.doc('users/$uid').get(),
tag: 'User.fetch',
);
// Write operation (counts against daily write quota)
await SintSentinel.guard(
() => FirebaseFirestore.instance.doc('logs/$id').set(data),
isWrite: true,
tag: 'Log.write',
);
// With fallback — returns cached data instead of throwing when blocked
final items = await SintSentinel.guard(
() => api.fetchItems(),
fallback: () => localCache.getItems(),
tag: 'Items.fetch',
);
4. Protect widgets (optional)
class _MyPageState extends State<MyPage> with SentinelRebuildMonitor {
@override
Widget build(BuildContext context) {
return Text('Safe from infinite rebuilds');
}
}
When a widget exceeds 30 rebuilds/second:
- Excess
setStatecalls are dropped silently (UI stays responsive). - A console warning fires once:
SintSentinel: Cyclic rebuild detected in MyPage. - If abuse continues (90+ throttled calls), the circuit trips and a snapshot is captured automatically.
Logger
sint_sentinel provides a centralized application-level logger via SintSentinel.logger. One import, one logger, everywhere in your app.
Basic Usage
import 'package:sint_sentinel/sint_sentinel.dart';
// All log levels available
SintSentinel.logger.t('Trace message'); // verbose/trace
SintSentinel.logger.d('Debug details'); // debug
SintSentinel.logger.i('App started'); // info
SintSentinel.logger.w('Slow response'); // warning
SintSentinel.logger.e('Request failed'); // error
SintSentinel.logger.f('Fatal crash'); // fatal/wtf
Re-exported Types
sint_sentinel re-exports core types from package:logger, so you don't need a separate dependency:
// These are all available from sint_sentinel directly:
import 'package:sint_sentinel/sint_sentinel.dart';
// Logger, Level, PrettyPrinter, LogPrinter, DateTimeFormat
Custom Logger
Replace the default logger with your own configuration:
SintSentinel.configureLogger(
Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 5,
lineLength: 80,
colors: true,
printEmojis: true,
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
),
level: Level.debug,
),
);
Automatic Guard Logging
SintSentinel.guard() automatically logs every operation:
// This single call:
await SintSentinel.guard(() => api.fetchUser(), tag: 'User.fetch');
// Produces logs like:
// 🛡️ [guard] User.fetch → OK (circuit: closed)
// 🛡️ [guard] User.fetch → BLOCKED (circuit: open)
// 🛡️ [guard] User.fetch → FAILED: TimeoutException (failures: 3/5)
Pattern: Service-Level Logger
For services that need frequent logging, use a static getter:
class MyApiClient {
static Logger get _log => SintSentinel.logger;
Future<void> fetchData() async {
_log.d('Fetching data from /api/data');
final response = await SintSentinel.guard(
() => http.get(Uri.parse('/api/data')),
tag: 'MyApi.fetchData',
);
_log.i('Data fetched: ${response.statusCode}');
}
}
Snapshots
Every time the circuit trips, sint_sentinel captures a SentinelSnapshot — a frozen picture of everything that was happening at that moment. Snapshots are persisted in Hive and available to any consumer.
// Capture a snapshot on demand
final snapshot = await SintSentinel.capture();
print(snapshot.securityStatus); // "CLEAN" or "ALERT_open"
print(snapshot.requestsLastMinute); // 58
print(snapshot.dailyReads); // 2847
print(snapshot.rebuildOffenders); // ["MyBrokenWidget"]
// Query history
final history = await SintSentinel.snapshotHistory; // Last 50 snapshots
final latest = await SintSentinel.latestSnapshot; // Most recent
final recent = await SintSentinel.recentSnapshots( // Last hour
Duration(hours: 1),
);
Use cases:
- AI integration — Feed snapshots to your AI agent so it understands app health and can reason about what happened.
- Crash forensics — After a user reports an issue, check what the circuit was doing at the time.
- Analytics — Track trip frequency, most common block reasons, which widgets cause loops.
Each snapshot contains:
SentinelSnapshot(
timestamp: DateTime,
circuitState: 'closed' | 'open' | 'halfOpen',
blockReason: 'Rate limit exceeded: 61/60 req/min',
consecutiveFailures: 3,
tripCount: 2,
requestsLastMinute: 61,
dailyReads: 2450,
dailyWrites: 89,
minuteUsagePercent: 101.6,
dailyReadUsagePercent: 81.6,
rebuildOffenders: ['StreamBuilderWidget'],
)
How It Works
Circuit Breaker State Machine
quota exceeded
or N failures
CLOSED ─────────────────────────────> OPEN ──> [snapshot captured]
^ |
| test requests succeed | openDuration expires
| v
<──────────────────────────────── HALF_OPEN
test fails ──────────────> OPEN ──> [snapshot captured]
- CLOSED: All requests pass through. Failures and traffic are counted.
- OPEN: All requests blocked immediately. Maintenance page shown. Snapshot captured. Timer counts down.
- HALF_OPEN: Limited test requests allowed. If they succeed, circuit recovers. If they fail, circuit trips again.
Exponential Backoff
Each consecutive trip increases the blocking duration:
| Trip | Duration |
|---|---|
| 1st | 30s |
| 2nd | 60s |
| 3rd | 120s |
| 4th | 240s |
| 5th+ | 300s (cap) |
A full reset (SintSentinel.reset()) clears the trip counter.
Dual Watchdog System
Traffic Watchdog monitors outbound requests:
- Sliding 60-second window (rate limit)
- Daily read counter (auto-resets every 24h)
- Daily write counter (separate quota)
- When exceeded:
"Rate limit exceeded: 61/60 req/min"or"Daily read quota exceeded: 3001/3000"
State Watchdog monitors inbound rebuilds:
- Per-widget rebuild timestamps (pruned to last 1 second)
- When threshold exceeded,
setStatecalls are dropped - Throttle count accumulated; trips circuit when multiplier reached
Configuration
Three presets cover most use cases:
SentinelConfig.development() // Relaxed — 120 req/min, 20k daily, no backoff
SentinelConfig.production() // Balanced — 60 req/min, 3k daily, backoff on
SentinelConfig.strict() // Aggressive — 30 req/min, 1.5k daily
Full Configuration
SentinelConfig(
maxRequestsPerMinute: 100,
maxDailyRequests: 5000,
maxDailyWrites: 1000,
maxRebuildsPerSecond: 40,
rebuildTripMultiplier: 3, // 40 * 3 = 120 throttled calls to trip
failureThreshold: 5,
openDuration: Duration(seconds: 30),
halfOpenTestRequests: 3,
enableExponentialBackoff: true,
)
| Parameter | Dev | Prod | Strict | What it controls |
|---|---|---|---|---|
maxRequestsPerMinute |
120 | 60 | 30 | Sliding window rate limit |
maxDailyRequests |
20,000 | 3,000 | 1,500 | Daily read quota |
maxDailyWrites |
5,000 | 500 | 200 | Daily write quota |
maxRebuildsPerSecond |
60 | 30 | 15 | Widget rebuild threshold |
failureThreshold |
10 | 5 | 3 | Consecutive failures to trip |
openDuration |
10s | 30s | 60s | Base blocking duration |
enableExponentialBackoff |
off | on | on | Double duration each trip |
Error Handling
try {
final result = await SintSentinel.guard(() => riskyCall());
} on SentinelBlockedException catch (e) {
// Circuit is OPEN — show cached data or retry later
showSnackbar('Service paused: ${e.reason}');
} catch (e) {
// The guarded operation itself threw
// (failure recorded, circuit may trip if threshold reached)
showSnackbar('Error: $e');
}
Real-Time Monitoring
// Full status map
final status = await SintSentinel.getStatus();
// {
// "circuitState": "closed",
// "blockReason": "",
// "tripCount": 0,
// "traffic": {
// "requestsLastMinute": 23,
// "dailyReads": 456,
// "dailyWrites": 12,
// "minuteUsagePercent": 38.3,
// "isHealthy": true
// },
// "rebuildOffenders": []
// }
// Quick checks
if (SintSentinel.isBlocking) {
showMaintenanceBanner();
}
// React to state changes in UI (GetX Rx)
Obx(() {
final state = SintSentinel.circuit!.state.value;
return Icon(
state == SentinelCircuitState.closed ? Icons.shield : Icons.warning,
);
});
Architecture
┌──────────────────┐
| SentinelApp | Root widget
| (shows maintenance| when OPEN)
└────────┬─────────┘
|
┌────────v─────────┐
| SintSentinel | Static API
| .guard() | .capture()
| .getStatus() | .snapshotHistory
└────────┬─────────┘
|
┌─────────────v──────────────┐
| SentinelCircuit |
| CLOSED / OPEN / HALF_OPEN |
| Rx<State> for reactive UI |
└──┬──────────┬──────────┬───┘
| | |
┌───────v──┐ ┌────v─────┐ ┌──v──────────┐
| Traffic | | State | | Snapshot |
| Watchdog | | Watchdog | | Store |
| (req/min | | (rebuild | | (Hive ring |
| daily) | | /sec) | | buffer x50)|
└───────────┘ └──────────┘ └─────────────┘
| | |
┌────v──────────v──────────v────┐
| Hive Persistence |
| sint_sentinel (state/traffic) |
| sint_sentinel_snapshots (log) |
└───────────────────────────────┘
Performance
Benchmark results (release mode, Apple M1):
| Operation | Overhead | Impact |
|---|---|---|
SintSentinel.guard() per call |
50-300 us | Negligible |
TrafficWatchdog.recordRequest() |
10-50 us | Minimal |
StateWatchdog.recordRebuild() |
1-5 us | Negligible |
| Cold init from Hive | 5-20 ms | One-time |
SentinelApp per-frame (CLOSED) |
0 us | Zero cost |
| Snapshot capture | ~1 ms | Only on trips |
The sentinel adds zero overhead per frame when the circuit is closed (normal operation). The Obx listener in SentinelApp only triggers a rebuild when the circuit state actually changes — a rare event.
The Incident That Started It All
On February 18, 2026, a Samsung S25 running the Open Neom app generated 165,000 Firestore document reads in under 4 hours while sitting idle on a desk. The cause: a StreamBuilder initialized inside build(), creating an infinite reconnection loop at ~700 requests/minute.
The total monthly bill had already reached 15 million reads in 30 days, with a single-day spike of 1.1 million reads. This wasn't a cyberattack — it was a coding mistake that simulated one.
sint_sentinel was built so that this can never happen again. Not in our apps, and not in yours.
Requirements
- Dart SDK
>=3.4.0 <4.0.0 - Flutter
>=3.22.0 - sint
^1.3.0 - hive_flutter
^1.1.0 - logger
^2.7.0
Platform Support
| Platform | Supported |
|---|---|
| Android | Yes |
| iOS | Yes |
| Web | Yes |
| macOS | Yes |
| Windows | Yes |
| Linux | Yes |
| WASM | Not yet |
WASM Compatibility Roadmap
sint_sentinel currently uses hive_flutter for persistence, which depends on dart:io and is not WASM-compatible. A future version will migrate to shared_preferences to achieve full WASM support.
The migration path is straightforward — sint_sentinel only persists simple key-value data (counters, timestamps, JSON-serialized snapshots). The public API (SintSentinel.guard(), .capture(), etc.) will remain unchanged.
| Storage need | Current (Hive) | Future (shared_preferences) |
|---|---|---|
dailyReads: 450 |
Box.put(key, 450) |
prefs.setInt(key, 450) |
timestamps: [1,2,3] |
Box.put(key, list) |
prefs.setString(key, jsonEncode(list)) |
snapshots: [...] |
Box.put(key, list) |
prefs.setString(key, jsonEncode(list)) |
If WASM support is critical for your project today, you can implement the migration yourself by replacing SentinelHiveController with a SharedPreferences-backed equivalent and passing it to SentinelCircuit.
License
MIT. See LICENSE.
Links
- SINT Framework — The state management framework sint_sentinel extends.
- Open Neom — The ecosystem where sint_sentinel was born and battle-tested.
- Changelog
Libraries
- sint_sentinel
- SintSentinel — Circuit Breaker & Rate Limiter for the SINT Framework.