Restart-intent exit codes that let systemd, Kubernetes, and s6 distinguish a recoverable blip from a fatal misconfiguration
The gateway now ships in the praisonai-bot package. praisonai serve gateway still works exactly as documented here; for a standalone install see praisonai-bot Migration.
praisonai serve gateway exits with a specific code to tell your supervisor whether to restart or stop — a misconfigured gateway should not crash-loop forever.
from praisonaiagents import Agentagent = Agent(name="assistant", instructions="Serve users via the gateway.")agent.start("Start the gateway under systemd supervision.")
The user runs the gateway under a supervisor; exit codes signal whether to restart, stop for config fixes, or exit cleanly.
The process exits with code 0 on clean shutdown, 75 on transient failure (supervisor should restart), and 78 on fatal config error (supervisor should alert, not restart).
2
Systemd service with correct restart policy
[Unit]Description=PraisonAI GatewayAfter=network.target[Service]ExecStart=/usr/local/bin/praisonai gateway start --agents /etc/praisonai/agents.yamlRestart=on-failureRestartSec=5# Restart on transient failure (75), but not on fatal config error (78)RestartForceExitStatus=75SuccessExitStatus=0[Install]WantedBy=multi-user.target
from praisonaiagents.gateway.protocols import FatalConfigErrorclass MyPlugin: def startup(self, config): if not config.get("api_key"): raise FatalConfigError("MyPlugin: 'api_key' is required in gateway.yaml")
The classifier (classify_exit_reason) is a pure function with no side effects. It lives in the core package so the wrapper CLI (praisonai serve gateway) and the runtime entry point (python -m praisonai.runtime) share one source of truth.
classify_exit_reason(exc) applies these rules in order:Clean exit (0):
exc is None
exc is KeyboardInterrupt (includes SIGTERM mapped to KeyboardInterrupt)
exc is SystemExit(0) or SystemExit(None)
exc is SystemExit(n) where n is any integer — passes n through unchanged
Fatal (78 — do not restart):
FatalConfigError raised explicitly anywhere in the start path
gateway.yaml missing, empty, or schema-invalid (load_gateway_config raises ValueError)
--agents file missing or unreadable
agents: key absent or falsy in the agents YAML
agents: is not a list (e.g. agents: {name: bad})
agents: list contains a non-mapping entry (e.g. agents: ["bad"]) — previously triggered AttributeError and crash-looped at code 75; now correctly raises FatalConfigError and exits 78
Missing required gateway dependencies at boot (e.g. pip install praisonai[api] not run)
Transient (75 — ask supervisor to restart):
Any Exception not matched by the rules above
Before this fix, a malformed gateway.yaml (missing agents: section, empty file, or schema-invalid YAML) caused load_gateway_config to raise ValueError, which routed through classify_exit_reason to exit 75 — a crash-loop that ran forever. It now exits 78. If your supervisor was relying on the old 75 behaviour to eventually surface the problem, add RestartPreventExitStatus=78 and a matching alert rule.
Kubernetes restartPolicy: OnFailure restarts on any non-zero exit, so exit 78 will still restart by default. Surface the fatal-config state via an init container or a wrapper script:
Kubernetes does not have a built-in RestartPreventExitStatus equivalent. Validate gateway.yaml in CI (see Best Practices) and use an init container to reject bad configs before the gateway pod starts.
Place this finish script alongside your run script. $1 is the exit code from run:
#!/bin/sh# finish — called by s6/runit after 'run' exits# $1 = exit code from runif [ "$1" = "78" ]; then # Fatal config — operator must fix gateway.yaml # Tell s6 to stop this service permanently (don't auto-restart) s6-svc -O .fi# For any other code (including 75), exit 0 to let the supervisor restartexit 0
For runit (finish):
#!/bin/sh# $1 = exit code, $2 = signalif [ "$1" = "78" ]; then # Signal the runsvdir to down this service sv down .fi
Use classify_exit_reason directly when you run the gateway start path from your own code:
import sysfrom praisonaiagents.gateway import classify_exit_reason, FatalConfigErrordef run_my_gateway(): # ... your gateway startup logic ... passtry: run_my_gateway()except Exception as exc: sys.exit(classify_exit_reason(exc))
Signal “stop restarting me” from anywhere in a custom start path by raising FatalConfigError:
from praisonaiagents.gateway import FatalConfigErrordef validate_my_config(path): if not config_is_valid(path): raise FatalConfigError(f"Invalid config at {path} — fix before restarting")
The wrapper (praisonai serve gateway) imports the exit-code symbols from praisonaiagents.gateway at startup. Two fallback rules apply:
If praisonaiagents.gateway is absent (ModuleNotFoundError) or predates the protocol (missing symbols → AttributeError), the wrapper uses local sysexits.h values (0 / 75 / 78) and a minimal classifier with the same semantics.
Any other ImportError (a broken core install) surfaces immediately — broken cores are no longer silently swallowed.
Pre-PR #2439 wrappers returned None from start(); the new int return is backward-compatible — callers that ignored the return value continue to work and see exit 0 semantics from the shell.
RestartForceExitStatus=75 tells systemd to restart even when Restart=on-failure would not normally trigger (e.g. the process exits quickly). Exit code 78 is NOT in RestartForceExitStatus, so systemd will not restart on fatal config errors.
Without it, a typo in gateway.yaml restarts the gateway indefinitely, burning through CPU, log storage, and pager budget.
RestartPreventExitStatus=78
Treat exit 78 as a paging event
Exit 75 is normal supervisor noise — the gateway will come back. Exit 78 means a human deployed a broken config and the gateway will never come back on its own.
# Example: alert on 78 from journaldjournalctl -u praisonai-gateway -f | grep "Fatal config"
Validate gateway.yaml in CI before rollout
A 78 at boot is cheap. A 78 caught mid-rollout after rolling out to 10 pods is not.
# In your CI pipeline, before deploying:praisonai serve gateway --config gateway.yaml# Exit 78 will fail the pipeline step
Don't swallow FatalConfigError without re-raising
Catching FatalConfigError and not re-raising converts exit 78 back to 75 and re-enables the crash-loop.
# Wrong — crash-loops forever on a bad configtry: run_my_gateway()except FatalConfigError: pass # Don't do this# Right — let the supervisor see 78try: run_my_gateway()except FatalConfigError as exc: sys.exit(78)
Always set RestartPreventExitStatus=78 in systemd
Without this, systemd will restart a misconfigured gateway in a tight crash loop, filling logs and burning CPU. Exit 78 is the standard way to say “don’t restart me”.
Raise FatalConfigError for unrecoverable plugin errors
If your plugin needs a required API key or a database connection that can’t be deferred, raise FatalConfigError at startup so the operator knows to fix the config before the gateway will run.
Monitor for exit 78 in your alerting
A 78 exit in production means a deployment broke the config. Wire it to your on-call alerting so it isn’t silently swallowed by a restart loop.
Exit 0 on clean shutdown
The gateway exits 0 when it receives a shutdown signal (SIGTERM, /drain endpoint). Supervisors that restart on any non-zero exit will leave the gateway alone after a graceful stop.
Never treat exit code 78 as a transient failure
Code 78 means the configuration is broken. Auto-restarting on 78 creates a restart loop that burns CPU without making progress. Always alert an operator first.
Add RestartSec to avoid restart storms
When restarting on exit code 75, add at least 5 seconds between restarts to avoid hammering a degraded upstream.
At startup, validate that all required API keys and config values are present. Raise FatalConfigError immediately rather than letting the process crash mid-run.
Log the exit code in your monitoring system
Route stdout/stderr to your log aggregator and alert on exit_code=78 to catch config regressions in CI/CD pipelines.