System Control
Two runtime safety controls for Baldur's self-healing: a kill switch that makes Baldur's automatic interventions step aside, and a dry-run mode that lets Baldur watch and report what it would do without acting. No redeploy, and every process sharing the state store picks up a flip within seconds.
What is it?
When something goes wrong in production, automation that is normally helpful can occasionally make an incident worse — retries pile onto an already-overloaded service, automated recovery fights the human operator who is trying to stabilize things by hand. In those moments you want one thing: a big red button that stops the automation now, without editing config files, redeploying, or restarting the app.
System Control gives you two such runtime controls:
- A kill switch: a global on/off flag for Baldur's automation. Flip it off and Baldur steps aside wherever dry-run would hold it back: a protected call runs once, with no retry, no circuit-breaker recording or refusal and no dead-letter capture, while what you configured on the call itself (a fallback, a timeout, an idempotency key, a bulkhead) and a breaker an operator blocked stay in force. Flip it back on and Baldur acts again.
- A dry-run (observe-only) mode: a rehearsal switch. With dry-run on, Baldur keeps watching every protected call, but instead of retrying, recording breaker failures or writing to the dead-letter queue, it logs what it holds back. A fallback or a timeout you configured still applies.
Both settings are stored — in Redis when your deployment names one, otherwise in a local file — and every process keeps its own copy, refreshed from the store every few seconds; see how far a flip reaches below.
Why it matters
The kill switch is an incident-response tool; dry-run mode is an adoption tool. Together they are aimed at the two scariest moments of running automation in production: turning it on for the first time, and turning it off in a hurry.
- No deploy, no restart. Flip either control at runtime through the admin console, a REST call, or a function call. The process that makes the flip acts on it at once, and every other process sharing the store follows within about five seconds.
- It survives a restart that can read the store. A restarted process reads the stored state before it serves anything, so after a crash or a restart it resumes the last state instead of silently re-arming automation. With the file store that holds only while the restarted process sees the same state directory: a replacement container with a fresh filesystem starts enabled and live.
- It is shared through the store. Every process reading the same store — every worker on a host with the file store, every server with Redis — applies the same state.
- A flip says what happened. A flip the store confirmed answers that it applies everywhere; one
the store could not confirm answers with HTTP 503, and its
appliesfield says where it does apply. - Flips are attributed. Disabling from the console or the API requires a reason. Each flip is
logged with the name of whoever made it, and the status view keeps who last disabled it, why and
when. On OSS the admin console records that name as
anonymous, and a durable audit-trail entry needs PRO. - Try before you trust. Before you let Baldur act on real traffic, run it in dry-run against that same traffic and read back which interventions it held back: each suppressed retry or breaker record is logged with the service it applied to, and so is a dead-letter write on a call without a retry stage. Dry-run does not simulate the breaker, so that log never says a circuit would have opened.
How it works in Baldur
System Control exposes two independent runtime controls — the kill switch and dry-run mode. They are orthogonal: you can engage the kill switch, run live, or run live but observe-only.
The kill switch
The kill switch has one piece of observable state (whether Baldur is enabled or disabled), and operators move it between the two:
stateDiagram-v2
[*] --> ENABLED
ENABLED --> DISABLED: operator flips the kill switch
DISABLED --> ENABLED: operator re-enables
- ENABLED is the normal state: Baldur's self-healing runs as usual.
- DISABLED is the kill-switch state. Baldur's automatic interventions step aside exactly where dry-run holds them back:
| Where it comes from | While the kill switch is off |
|---|---|
protect(), aprotect(), @protected, @retry, @dlq_protect and the preset pipelines (standard_pipeline() and the others) |
Your function runs once: no retry, no circuit-breaker record or refusal, no dead-letter capture. A fallback= still answers a failure, a timeout= still cuts a slow call, and an idempotency key and a bulkhead still apply |
| A Celery task that fails | No circuit-breaker record and no dead-letter capture; retries you set on the task in Celery itself still run |
| A breaker that opened automatically before the flip | Stops refusing calls while the switch is off and keeps its stored state; once you re-enable it picks up where it stood, refusing again or, if its recovery wait ran out meanwhile, letting a few trial calls through |
| A breaker an operator blocked (the console's Block, or a forced open in code) | Still refused, on the outbound path and in the web middleware — the Block is your instruction, not an automatic intervention |
| Forcing a breaker open or closed while the switch is off | Refused unless code passes override_kill_switch=True; resetting a breaker still goes through |
| Dead-letter replay | Refused with PRO active; on OSS alone, replay does not check the switch |
A few paths are not reached by the kill switch because dry-run does not reach them either: a
tenacity-bridge retry stage, the failures Baldur's web middleware records on a breaker from HTTP
responses (they keep counting and can trip a breaker that refuses once the switch is back on), the
Django connection-pool breaker, the preset pipelines' PRO error-budget guard, and your own direct
calls to should_allow_request(). get_execution_mode() reports shadow while the switch is off.
Emergency load shedding follows the emergency level, not the kill switch.
Unlike dry-run, the kill switch logs nothing per call: an incident at request rate would otherwise
write thousands of lines a second. The process that made the flip logs the flip itself, and every
other process logs system_control.state_changed at INFO once, when its copy picks up the change.
| What you observe | When it happens |
|---|---|
| Self-healing runs normally | ENABLED — the default state |
| Protected calls run once with no retry, breaker record or dead-letter capture, while fallbacks, timeouts and blocked breakers still apply | DISABLED — an operator flipped the kill switch |
A disable is rejected with reason is required (HTTP 400) |
You disabled from the console or the API without a reason; a disable() call in code does not ask for one |
| Baldur comes back up still disabled after a restart | The restarted process read the stored state; with the file store that needs the same state directory |
| Another worker or server still behaves as enabled a few seconds after you disabled | It has not refreshed its copy yet; every process sharing the store applies the flip within about five seconds |
Dry-run (observe-only) mode
Independently of whether Baldur is enabled, you can put it into dry-run mode. In dry-run, Baldur
still sees every protected call but suppresses its healing interventions (automated retries,
dead-letter writes, and the circuit breaker's recording and rejecting) and logs each one it held back
instead. A few things stay live because they answer the call in front of them rather than a failure
history: a fallback= still answers a failed call, and Baldur's own timeout= still cuts a slow one.
stateDiagram-v2
[*] --> LIVE
LIVE --> DRY_RUN: operator turns on dry-run
DRY_RUN --> LIVE: operator confirms "go live"
- LIVE is the normal state: when Baldur decides to intervene, it actually intervenes.
- DRY_RUN is observe-only. Each held-back intervention is logged at INFO as
execution_mode.intervention_suppressed, naming the action (retry,circuit_breaker_record,dlq_storeand so on) and the service, so you can review the "would-have" timeline before trusting it for real. On a call with a retry stage, such as@dlq_protect, the timeline stops at the held-back retry: the dead-letter write its exhausted retries would have made is not logged. On the protected call paths the breaker records nothing while dry-run is on, so that timeline shows the failures it would have counted, never a trip; the failures Baldur's web middleware records from HTTP responses are the exception and still count. A Block an operator forces stays in force during dry-run: a blocked breaker is refused as on a live system.
| What you observe | When it happens |
|---|---|
| Baldur acts on its decisions | LIVE — the default |
| Baldur makes no retry, breaker record or dead-letter write and logs the ones it held back; fallbacks and timeouts still apply | DRY_RUN — observe-only mode is on |
| A "go live" request is rejected unless you confirm it | Leaving dry-run from the console or the API takes an explicit confirmation ("confirm": true); disable_dry_run() in code does not ask |
Flipping the controls
Where you flip them, where the state lives, how far a flip reaches, and what a failure looks like:
- Where you flip them. The admin console has a System Control row with the current state and
the enable/disable and dry-run controls. The admin server behind it answers the same actions as
REST calls, and a Django app also gets them under its own API routes. The console and the admin
server refuse every flip with a
403untilBALDUR_ADMIN_UNLOCK=1is set, so set it before an incident rather than during one. In code,is_baldur_enabled()andis_dry_run()(frombaldur.services.system_control) read the state, andget_system_control()returns the object whoseenable(),disable(),enable_dry_run()anddisable_dry_run()flip it; each returns aSystemControlChangewhosepersistedandappliessay what happened. - Where the state is stored. Set
BALDUR_SYSTEM_CONTROL_BACKENDto choose. When it is not set, Baldur uses Redis if a Redis URL is named —BALDUR_SYSTEM_CONTROL_REDIS_URL, orBALDUR_REDIS_URLas an environment variable or a Django setting — and a local file store otherwise. The file store lives inBALDUR_SYSTEM_CONTROL_DIR(defaultlogs/baldur_state). A relative directory resolves against each process's working directory, so two processes started from different directories keep two separate stores: set an absolute path when yours do. The status response shows the directory each process resolved. (A memory-only backend exists for tests; it does not survive a restart.) - Moving to Redis leaves file state behind. When the backend is chosen because a Redis URL is
named and the file directory still holds state from before, that state is not copied: the process
logs one warning naming the directory. Set
BALDUR_SYSTEM_CONTROL_BACKEND=fileto keep using it. - Every process keeps its own copy, refreshed every five seconds. Each process reads the store
once when Baldur starts (at
init(), or the first time it reads the switch if it never callsinit()) and then re-reads it every five seconds. After that first read, reading the switch never touches the store. A flip reaches the process that made it at once and every other process sharing the store within about five seconds, with no restart. A flip reversed within one refresh may never be seen by another process at all. - An unreachable store slows only a starting process. If the store cannot be reached, or cannot
be set up (Redis not answering, a file directory it cannot write), every process keeps acting on
the last state it read, enabled and live if it never read one. Protected calls do not wait for
the store, apart from the first calls of a process that is just starting, which can wait out one
connection attempt. The process logs a
control_state.refresh_failedwarning once per outage, and the status response reportsstore_reachable: false, a growingstate_age_secondsand thelast_store_error; thebaldur_control_state_refreshed_timestamp_secondsgauge stops advancing. - A flip the store could not confirm answers 503. Every flip response carries
persisted(truewhen the store holds it,falsewhen it was not applied there,nullwhen the outcome is unknown and is decided by the next successful read) andapplies. A kill switch or a dry-run turned on that the store could not confirm is held in the process that received it (applies: this_process): that process acts on it and retries the write on every refresh, and the status response reportspersist_dirty: trueuntil the write lands. It lasts only as long as that process — a worker your server recycles drops it — and it is dropped, with a warning, when the store shows a change to the switch that process had not seen. A re-enable or a "go live" that the store could not confirm is in force nowhere (applies: none); in code it raisesSystemControlStoreError. A flip that withdraws a change the same process was still holding says so (withdrew_held_change: true).
Configuration
System Control is operated at runtime: you flip the kill switch and toggle dry-run from the admin console, the API, or code. These settings decide where the state is kept and who may flip it; set them before you need them.
| Env Var | Default | What it controls |
|---|---|---|
BALDUR_ADMIN_UNLOCK |
false |
Set to 1 so the console and the admin server's REST calls can flip either control; until then they are refused with a 403 |
BALDUR_SYSTEM_CONTROL_BACKEND |
not set | Where the switch state is stored: redis or file. When not set, redis if a Redis URL is named (below), otherwise file |
BALDUR_SYSTEM_CONTROL_REDIS_URL |
Redis URL for the switch state; when empty, BALDUR_REDIS_URL is used |
|
BALDUR_REDIS_URL |
redis://localhost:6379/0 |
Redis connection used by the Redis store (and by the rest of Baldur); naming it selects the Redis store when no backend is set |
BALDUR_SYSTEM_CONTROL_DIR |
logs/baldur_state |
Directory of the file store; a relative path resolves against each process's working directory |
The complete operator-tunable list lives in the environment variables reference.
See also
- Getting Started — set it up
- Circuit Breaker — what dry-run holds back on a breaker, and the manual Block/Allow
- Environment Variables — the complete operator-tunable list