Reloading Configuration on SIGHUP in asyncio¶
SIGHUP has meant "re-read your configuration" for long-running daemons since well before anyone was writing async services, and the convention is still the cheapest way to change a timeout, flip a feature flag or raise a log level without dropping connections. In asyncio the mechanics are easy — loop.add_signal_handler delivers the signal as a callback on the loop — and the risks are all in what happens next: a handler that does file I/O on the loop, a configuration object that is mutated while requests are reading it, a typo in the new file that takes the service down, or five signals in a row that trigger five concurrent reloads. This guide handles each, verified end to end against a running process.
Prerequisites¶
- Python 3.11+ on Unix.
loop.add_signal_handleris not available on Windows. - Signal handling from Graceful Shutdown & Signals — the same mechanism used for
SIGTERM. - The alternative trigger in watching files for changes in asyncio, if nobody is available to send the signal.
1. Register the handler and do nothing in it¶
loop.add_signal_handler runs the callback on the event loop, between other callbacks, so it is ordinary Python rather than a real signal context — but it still blocks the loop for as long as it runs. The handler's only job is to wake a supervisor:
loop = asyncio.get_running_loop()
hups: asyncio.Queue = asyncio.Queue()
loop.add_signal_handler(signal.SIGHUP, hups.put_nowait, None)
async def supervisor(settings) -> None:
while True:
await hups.get()
while not hups.empty():
hups.get_nowait() # coalesce a burst
await settings.reload()
The drain loop is worth the two lines: five rapid SIGHUPs produced exactly one reload in the verified run. Without it, a config-management tool that signals once per changed file triggers a reload per signal, each reading the same file.
Use add_signal_handler, not signal.signal. The latter runs the handler in the real signal context, from which touching asyncio objects is unsafe, and its callback can interrupt the loop at an arbitrary bytecode boundary.
Verify: several signals in quick succession produce a single reload.
2. Parse, validate, then swap¶
The reload reads the file off the loop, parses it into an immutable object, validates it, and only then rebinds:
@dataclasses.dataclass(frozen=True)
class Config:
workers: int
timeout: float
feature_x: bool
def parse(text: str) -> Config:
raw = json.loads(text)
cfg = Config(int(raw["workers"]), float(raw["timeout"]), bool(raw.get("feature_x", False)))
if not 1 <= cfg.workers <= 64:
raise ValueError(f"workers out of range: {cfg.workers}")
if not 0 < cfg.timeout <= 60:
raise ValueError(f"timeout out of range: {cfg.timeout}")
return cfg
async def reload(self) -> None:
text = await asyncio.to_thread(pathlib.Path(self.path).read_text) # blocking I/O
try:
new = parse(text)
except Exception as exc:
log.error("reload rejected (%s); keeping current config", exc)
return # the old config stays live
old, self._cfg = self._cfg, new # one atomic rebind
log.info("reloaded: %s", diff(old, new))
Verified against two bad files in a row — workers: 999 and a file that was not JSON at all — the service logged reload rejected twice and kept serving with the previous values. A valid file produced a field-by-field diff: {'workers': (4, 8), 'timeout': (2.5, 5.0), 'feature_x': (False, True)}, which is the log line you want at 3 a.m.
Rebinding a name is atomic in CPython, so readers see either the old object or the new one, never a half-updated dict. That property is why the config is a frozen dataclass rather than something mutated in place.
Verify: an invalid config produces a log line and no behaviour change.
3. Read a snapshot, once per request¶
If a request reads settings.current.timeout at the start and settings.current.retries later, a reload in between gives it a mix of two versions. Take one snapshot and use it throughout:
async def handle(request):
cfg = settings.current # one read, one version
async with asyncio.timeout(cfg.timeout):
return await do_work(request, workers=cfg.workers)
Verified: an in-flight request that had snapshotted (workers=4, timeout=2.5) still saw those values after a reload changed them, while the next request saw (8, 5.0). That is the correct semantics — configuration changes at request boundaries, not inside them.
For deeper call stacks, a contextvar set once per request avoids threading the object through every signature, in the same way as propagating deadlines with contextvars. Long-lived background tasks should re-read the snapshot at the top of each iteration, so they pick up changes without restarting.
Verify: a reload during a slow request does not change that request's behaviour mid-flight.
4. Know what a reload cannot change¶
Rebinding a value only works for settings that are read fresh. Anything already materialised needs a migration path or a restart:
- Read per use — timeouts, retry counts, feature flags, rate limits, log levels. These reload cleanly.
- Materialised but resizable — connection pool sizes, semaphore limits, worker counts. A new value in the config changes nothing until you also resize the object; some libraries support it, many do not.
- Bound at startup — listening sockets, TLS certificates, the event loop policy, the process's user. These need a restart, or a socket-handover mechanism such as systemd socket activation.
Make the distinction explicit in the config schema rather than leaving it to discovery:
RELOADABLE = {"timeout", "retries", "feature_x", "log_level"}
for field in changed:
if field not in RELOADABLE:
log.warning("%s changed but requires a restart to take effect", field)
That warning turns "we changed it and nothing happened" into a known limitation — a much shorter conversation.
Verify: changing a restart-only field logs a warning instead of silently doing nothing.
5. Make reloads observable¶
A reload is a change to production behaviour, and it should leave the same trail as a deploy: a counter of successful and rejected reloads, the log line with the diff, and a gauge holding a config version or file hash so you can confirm every instance is running the same thing.
CONFIG_RELOADS = Counter("config_reloads_total", labelnames=["outcome"])
CONFIG_VERSION = Gauge("config_version_hash", "First 8 hex chars of the config hash as an int")
The fleet-wide check matters most. A SIGHUP delivered to nine of ten instances leaves one running the old values, and without a version gauge that instance is indistinguishable from the others until its behaviour differs. Expose the current values on an admin endpoint too — with secrets redacted — so "what is this instance actually using?" is answerable without a shell.
Verify: after a fleet-wide reload, every instance reports the same config version.
Verification¶
Configuration reloading is safe when:
- The signal handler only enqueues, and bursts coalesce into one reload.
- File I/O happens off the loop, via a thread.
- Invalid configuration is rejected with the previous values retained.
- The swap is a single rebind of an immutable object.
- Requests snapshot once and are unaffected mid-flight.
- Non-reloadable changes warn, and reloads are counted and versioned.
Pitfalls & edge cases¶
signal.signalinstead ofadd_signal_handler. The callback runs in a real signal context where asyncio objects are unsafe to touch.- Blocking file reads in the reload. A config on a network filesystem can stall the loop for seconds.
- Mutating the config object in place. Readers see a torn state; rebind a frozen object instead.
- Signals inside containers.
SIGHUPmust reach PID 1 or be forwarded;docker kill -s HUPsends it to PID 1 only. - Partial writes. A config written in place can be read half-complete; write to a temporary file and rename, and have the writer do the same.
- Secrets in the diff log. Redact credential fields before logging what changed.
Frequently Asked Questions¶
How do I handle SIGHUP in an asyncio application?
Call loop.add_signal_handler(signal.SIGHUP, callback) and have the callback only enqueue a token. A supervisor task awaits the queue, drains any burst, and performs the reload. Never use signal.signal for this, because its handler runs in a real signal context where touching asyncio objects is unsafe.
How do I reload configuration without restarting a Python service?
Read the file in a thread, parse it into a frozen dataclass, validate every field, and only then rebind the shared name. Rebinding is atomic in CPython, so readers see either the old configuration or the new one. Requests should snapshot the object once so they never mix two versions.
What happens if the new configuration file is invalid?
Nothing, if the reload validates before swapping. Verified with an out-of-range value and a file that was not JSON at all, the service logged "reload rejected" for each and kept serving with the previous configuration. Rejections should be counted and alerted on, since the fleet is now running an older config than intended.
Can I reload connection pool sizes or listening ports?
Pool sizes only if the library exposes a resize operation — a new number in the config changes nothing on its own. Listening sockets and TLS certificates are bound at startup and need a restart or a socket-handover mechanism. Warn explicitly when a restart-only field changes.
Should I use SIGHUP or watch the config file?
SIGHUP when a deploy tool or operator can send it, because the trigger is explicit and auditable. File watching when nothing can signal the process — a mounted config map, for example — accepting that saves are noisier and need debouncing. Both end in the same validated, atomic swap.
Related¶
- Graceful Shutdown & Signals — up to the topic overview for signal handling.
- Watching files for changes in asyncio — the other way to notice a configuration change.
- Health and readiness probes — where a config version belongs.
- Resilience, Cancellation & Error Handling — the section overview.