Pipeline health reporting for process supervisors.
A pipeline that hangs still has a live process, so a supervisor watching only
the PID never restarts it. :meth:sgn.apps.Pipeline.run therefore reports
progress, as three lifecycle events on the run loop:
ready
The graph has been checked, every element's on_startup has run, and
the first stride is about to execute.
alive
The graph loop is about to execute a stride. The heartbeat comes from the
main process's loop, so under Parallelize a stalled worker (which
stalls that loop at a queue) stops it, as intended.
stopping
The run loop is exiting, at end-of-stream or on failure.
Every event is stamped into one :class:HealthState, and each supervisor
gets it through a transport that suits it. Supervisors come in two kinds:
- Push. :class:
Systemd speaks sd_notify(3): READY=1 /
WATCHDOG=1 / STOPPING=1 datagrams (with a STATUS= line) to
$NOTIFY_SOCKET. systemd sets that variable for Type=notify
services; Podman proxies it into containers (--sdnotify=container).
- Pull. :class:
HTTPHealth serves GET /readyz and GET /healthz
(200 or 503, JSON body) from :class:sgn.control.HTTPControl's server, for
Kubernetes httpGet probes, Docker HEALTHCHECK, load balancers and
monitoring. A pipeline already running inside with HTTPControl() gets
the routes on its control port; one that is not can have the server
started for the run alone.
Pipeline.run(health=None) selects transports from the environment
(:func:from_environment): NOTIFY_SOCKET enables :class:Systemd and
SGN_HEALTH_PORT enables :class:HTTPHealth. Custom transports subclass
:class:HealthReporter. Once a run has started, reporting never raises into
it: a failing reporter is logged and skipped.
HTTPHealth
Bases: HealthReporter
flowchart TD
sgn.health.HTTPHealth[HTTPHealth]
sgn.health.HealthReporter[HealthReporter]
sgn.health.HealthReporter --> sgn.health.HTTPHealth
click sgn.health.HTTPHealth href "" "sgn.health.HTTPHealth"
click sgn.health.HealthReporter href "" "sgn.health.HealthReporter"
Serve GET /readyz and GET /healthz on HTTPControl's server.
/readyz answers 200 between ready and stopping and 503
otherwise (starting, or draining); /healthz answers 200 while the
loop has strided within max_age and 503 once it goes stale. Both
return the :meth:HealthState.snapshot as JSON. A stuck loop gives a
stale stamp, a wedged process gives a timeout; a supervisor treats either
as failure.
If the server is already running -- the pipeline runs inside
with HTTPControl() -- the routes appear on its port and host and
port here are not needed. Otherwise :meth:attach enters an
HTTPControl(registry_file=None, signals=False) of its own on
host:port for the run alone -- the server, without the registry
file or signal handlers -- and :meth:detach exits it. address
holds that server's (host, port) while it runs.
Parameters:
| Name |
Type |
Description |
Default |
port
|
int | None
|
Port to serve on if the server is not already running; None
or 0 lets the OS choose (logged, but of little use to a probe).
|
None
|
host
|
str
|
Interface to bind; 0.0.0.0 reaches both a kubelet probing
the pod IP and a HEALTHCHECK hitting localhost.
|
'0.0.0.0'
|
max_age
|
float | None
|
Overrides HealthState.max_age for this run.
|
None
|
Source code in src/sgn/health.py
| class HTTPHealth(HealthReporter):
"""Serve ``GET /readyz`` and ``GET /healthz`` on ``HTTPControl``'s server.
``/readyz`` answers 200 between ``ready`` and ``stopping`` and 503
otherwise (starting, or draining); ``/healthz`` answers 200 while the
loop has strided within ``max_age`` and 503 once it goes stale. Both
return the :meth:`HealthState.snapshot` as JSON. A stuck loop gives a
stale stamp, a wedged process gives a timeout; a supervisor treats either
as failure.
If the server is already running -- the pipeline runs inside
``with HTTPControl()`` -- the routes appear on its port and ``host`` and
``port`` here are not needed. Otherwise :meth:`attach` enters an
``HTTPControl(registry_file=None, signals=False)`` of its own on
``host:port`` for the run alone -- the server, without the registry
file or signal handlers -- and :meth:`detach` exits it. ``address``
holds that server's ``(host, port)`` while it runs.
Args:
port: Port to serve on if the server is not already running; ``None``
or ``0`` lets the OS choose (logged, but of little use to a probe).
host: Interface to bind; ``0.0.0.0`` reaches both a kubelet probing
the pod IP and a ``HEALTHCHECK`` hitting ``localhost``.
max_age: Overrides ``HealthState.max_age`` for this run.
"""
heartbeat_interval = 0.0
def __init__(
self,
port: int | None = None,
host: str = "0.0.0.0", # noqa: S104 -- probes come from outside
max_age: float | None = None,
) -> None:
self.port = port
self.host = host
self.max_age = max_age
self.address: tuple[str, int] | None = None
self._control: HTTPControl | None = None
def attach(self, state: HealthState) -> None:
# control imports the sgn package, so it cannot be imported at module
# level from here (apps imports this module).
from sgn.control import HTTPControl
if self.max_age is not None:
state.max_age = self.max_age
if _http_control_serving():
logger.info(
"health routes at /readyz and /healthz on the HTTPControl server"
)
else:
control = HTTPControl(
registry_file=None, host=self.host, port=self.port or 0, signals=False
)
control.__enter__()
self._control = control
self.address = (self.host, control.bound_port)
logger.info(
"health routes at http://%s:%d/readyz and /healthz", *self.address
)
HTTPControl.health = state
def detach(self) -> None:
from sgn.control import HTTPControl
HTTPControl.health = None
if self._control is not None:
self._control.__exit__(None, None, None)
self._control = None
self.address = None
def __repr__(self) -> str:
return (
f"{type(self).__name__}(port={self.port!r}, host={self.host!r}, "
f"max_age={self.max_age!r})"
)
|
HealthReporter
Base class for a health transport; override what the supervisor needs.
The pipeline calls these from its event-loop thread, in order:
:meth:attach and :meth:ready once, :meth:alive repeatedly,
:meth:stopping and :meth:detach once (also when the run fails).
:meth:attach may raise to abort the run before startup (a port in use,
say); the others may raise too, but the pipeline logs that and continues.
Source code in src/sgn/health.py
| class HealthReporter:
"""Base class for a health transport; override what the supervisor needs.
The pipeline calls these from its event-loop thread, in order:
:meth:`attach` and :meth:`ready` once, :meth:`alive` repeatedly,
:meth:`stopping` and :meth:`detach` once (also when the run fails).
:meth:`attach` may raise to abort the run before startup (a port in use,
say); the others may raise too, but the pipeline logs that and continues.
"""
heartbeat_interval: float = 1.0
"""Minimum seconds between :meth:`alive` calls; ``0`` reports every stride."""
def attach(self, state: HealthState) -> None:
"""The run is about to start; ``state`` will be stamped as it goes."""
def ready(self) -> None:
"""The pipeline has started and is about to execute its first stride."""
def alive(self) -> None:
"""The pipeline is about to execute a stride (a liveness heartbeat)."""
def stopping(self) -> None:
"""The pipeline's run loop is exiting (end-of-stream or failure)."""
def detach(self) -> None:
"""``run()`` is returning; release anything :meth:`attach` acquired."""
|
heartbeat_interval = 1.0
class-attribute
instance-attribute
Minimum seconds between :meth:alive calls; 0 reports every stride.
alive()
The pipeline is about to execute a stride (a liveness heartbeat).
Source code in src/sgn/health.py
| def alive(self) -> None:
"""The pipeline is about to execute a stride (a liveness heartbeat)."""
|
attach(state)
The run is about to start; state will be stamped as it goes.
Source code in src/sgn/health.py
| def attach(self, state: HealthState) -> None:
"""The run is about to start; ``state`` will be stamped as it goes."""
|
detach()
run() is returning; release anything :meth:attach acquired.
Source code in src/sgn/health.py
| def detach(self) -> None:
"""``run()`` is returning; release anything :meth:`attach` acquired."""
|
ready()
The pipeline has started and is about to execute its first stride.
Source code in src/sgn/health.py
| def ready(self) -> None:
"""The pipeline has started and is about to execute its first stride."""
|
stopping()
The pipeline's run loop is exiting (end-of-stream or failure).
Source code in src/sgn/health.py
| def stopping(self) -> None:
"""The pipeline's run loop is exiting (end-of-stream or failure)."""
|
HealthState
dataclass
What the run loop has done so far; stamped by :class:PipelineHealth.
Timestamps are time.monotonic() values. max_age is how long the
loop may go without a stride before :meth:is_alive turns false.
Source code in src/sgn/health.py
| @dataclass
class HealthState:
"""What the run loop has done so far; stamped by :class:`PipelineHealth`.
Timestamps are ``time.monotonic()`` values. ``max_age`` is how long the
loop may go without a stride before :meth:`is_alive` turns false.
"""
max_age: float = 30.0
ready_at: float | None = None
last_alive: float | None = None
strides: int = 0
stopping_at: float | None = None
def age(self, now: float | None = None) -> float | None:
"""Seconds since the last stride, or ``None`` before the first."""
if self.last_alive is None:
return None
return (time.monotonic() if now is None else now) - self.last_alive
def is_ready(self) -> bool:
"""Between ``ready`` and ``stopping``: fit to receive work."""
return self.ready_at is not None and self.stopping_at is None
def is_alive(self, now: float | None = None) -> bool:
"""The loop has strided within ``max_age``.
Counts as alive before the first stride, so a slow startup is not
mistaken for a hang; bound startup with a startup probe or
``TimeoutStartSec=`` instead. After ``stopping`` the stamp goes
stale on its own, which is how a hung shutdown gets noticed.
"""
age = self.age(now)
if age is None:
return self.stopping_at is None
return age < self.max_age
def status(self, now: float | None = None) -> str:
"""``starting``, ``running``, ``stale`` or ``stopping``."""
if self.stopping_at is not None:
return "stopping"
if self.ready_at is None:
return "starting"
return "running" if self.is_alive(now) else "stale"
def summary(self, now: float | None = None) -> str:
"""One line for humans: ``running, 12304 strides, last 0.4s ago``."""
parts = [self.status(now), f"{self.strides} strides"]
age = self.age(now)
if age is not None:
parts.append(f"last {age:.1f}s ago")
return ", ".join(parts)
def snapshot(self, now: float | None = None) -> dict:
"""The JSON body of the HTTP routes."""
age = self.age(now)
return {
"status": self.status(now),
"ready": self.is_ready(),
"alive": self.is_alive(now),
"strides": self.strides,
"last_stride_age_s": None if age is None else round(age, 3),
"max_age_s": self.max_age,
}
|
age(now=None)
Seconds since the last stride, or None before the first.
Source code in src/sgn/health.py
| def age(self, now: float | None = None) -> float | None:
"""Seconds since the last stride, or ``None`` before the first."""
if self.last_alive is None:
return None
return (time.monotonic() if now is None else now) - self.last_alive
|
is_alive(now=None)
The loop has strided within max_age.
Counts as alive before the first stride, so a slow startup is not
mistaken for a hang; bound startup with a startup probe or
TimeoutStartSec= instead. After stopping the stamp goes
stale on its own, which is how a hung shutdown gets noticed.
Source code in src/sgn/health.py
| def is_alive(self, now: float | None = None) -> bool:
"""The loop has strided within ``max_age``.
Counts as alive before the first stride, so a slow startup is not
mistaken for a hang; bound startup with a startup probe or
``TimeoutStartSec=`` instead. After ``stopping`` the stamp goes
stale on its own, which is how a hung shutdown gets noticed.
"""
age = self.age(now)
if age is None:
return self.stopping_at is None
return age < self.max_age
|
is_ready()
Between ready and stopping: fit to receive work.
Source code in src/sgn/health.py
| def is_ready(self) -> bool:
"""Between ``ready`` and ``stopping``: fit to receive work."""
return self.ready_at is not None and self.stopping_at is None
|
snapshot(now=None)
The JSON body of the HTTP routes.
Source code in src/sgn/health.py
| def snapshot(self, now: float | None = None) -> dict:
"""The JSON body of the HTTP routes."""
age = self.age(now)
return {
"status": self.status(now),
"ready": self.is_ready(),
"alive": self.is_alive(now),
"strides": self.strides,
"last_stride_age_s": None if age is None else round(age, 3),
"max_age_s": self.max_age,
}
|
status(now=None)
starting, running, stale or stopping.
Source code in src/sgn/health.py
| def status(self, now: float | None = None) -> str:
"""``starting``, ``running``, ``stale`` or ``stopping``."""
if self.stopping_at is not None:
return "stopping"
if self.ready_at is None:
return "starting"
return "running" if self.is_alive(now) else "stale"
|
summary(now=None)
One line for humans: running, 12304 strides, last 0.4s ago.
Source code in src/sgn/health.py
| def summary(self, now: float | None = None) -> str:
"""One line for humans: ``running, 12304 strides, last 0.4s ago``."""
parts = [self.status(now), f"{self.strides} strides"]
age = self.age(now)
if age is not None:
parts.append(f"last {age:.1f}s ago")
return ", ".join(parts)
|
PipelineHealth
Bases: HealthReporter
flowchart TD
sgn.health.PipelineHealth[PipelineHealth]
sgn.health.HealthReporter[HealthReporter]
sgn.health.HealthReporter --> sgn.health.PipelineHealth
click sgn.health.PipelineHealth href "" "sgn.health.PipelineHealth"
click sgn.health.HealthReporter href "" "sgn.health.HealthReporter"
The pipeline's side of the seam: one state, many reporters.
Stamps :attr:state on every event, then delivers the event to each
reporter -- :meth:alive throttled to the reporter's
heartbeat_interval. :meth:attach propagates a failure (and detaches
what was attached) so a misconfigured transport aborts the run before
startup; every later event isolates the pipeline from reporter failures,
logging them at WARNING the first time, DEBUG while they persist, and
INFO on recovery.
Source code in src/sgn/health.py
| class PipelineHealth(HealthReporter):
"""The pipeline's side of the seam: one state, many reporters.
Stamps :attr:`state` on every event, then delivers the event to each
reporter -- :meth:`alive` throttled to the reporter's
``heartbeat_interval``. :meth:`attach` propagates a failure (and detaches
what was attached) so a misconfigured transport aborts the run before
startup; every later event isolates the pipeline from reporter failures,
logging them at WARNING the first time, DEBUG while they persist, and
INFO on recovery.
"""
heartbeat_interval = 0.0
def __init__(
self, reporters: Iterable[HealthReporter] = (), state: HealthState | None = None
) -> None:
self._slots = [_Slot(reporter) for reporter in reporters]
self.state = HealthState() if state is None else state
@classmethod
def resolve(
cls, health: HealthReporter | Iterable[HealthReporter] | None
) -> PipelineHealth:
"""Build from ``Pipeline.run``'s ``health`` argument.
``None`` consults the environment (:func:`from_environment`). A single
:class:`HealthReporter` or an iterable of them is used as given, so an
empty iterable disables reporting whatever the environment says.
"""
if health is None:
reporters = from_environment()
elif isinstance(health, HealthReporter):
reporters = [health]
else:
reporters = list(health)
for reporter in reporters:
if not isinstance(reporter, HealthReporter):
raise TypeError(
f"health reporters must be sgn.health.HealthReporter instances, "
f"got {reporter!r}"
)
logger.info("reporting pipeline health to %r", reporter)
return cls(reporters)
@property
def reporters(self) -> list[HealthReporter]:
return [slot.reporter for slot in self._slots]
def __bool__(self) -> bool:
return bool(self._slots)
def attach(self, state: HealthState | None = None) -> None:
if state is not None:
self.state = state
for slot in self._slots:
try:
slot.reporter.attach(self.state)
except BaseException:
self.detach()
raise
slot.attached = True
def ready(self) -> None:
self.state.ready_at = time.monotonic()
for slot in self._slots:
self._deliver(slot, "ready")
def alive(self) -> None:
now = time.monotonic()
self.state.last_alive = now
self.state.strides += 1
for slot in self._slots:
if now - slot.last_alive >= slot.reporter.heartbeat_interval:
slot.last_alive = now
self._deliver(slot, "alive")
def stopping(self) -> None:
self.state.stopping_at = time.monotonic()
for slot in self._slots:
self._deliver(slot, "stopping")
def detach(self) -> None:
for slot in self._slots:
if slot.attached:
slot.attached = False
self._deliver(slot, "detach")
def _deliver(self, slot: _Slot, event: str) -> None:
try:
getattr(slot.reporter, event)()
except Exception as exc: # noqa: BLE001 -- must never take down the run
level = logging.DEBUG if slot.failing else logging.WARNING
slot.failing = True
logger.log(
level, "health reporter %r failed on %s: %s", slot.reporter, event, exc
)
return
if slot.failing:
slot.failing = False
logger.info("health reporter %r recovered", slot.reporter)
|
resolve(health)
classmethod
Build from Pipeline.run's health argument.
None consults the environment (:func:from_environment). A single
:class:HealthReporter or an iterable of them is used as given, so an
empty iterable disables reporting whatever the environment says.
Source code in src/sgn/health.py
| @classmethod
def resolve(
cls, health: HealthReporter | Iterable[HealthReporter] | None
) -> PipelineHealth:
"""Build from ``Pipeline.run``'s ``health`` argument.
``None`` consults the environment (:func:`from_environment`). A single
:class:`HealthReporter` or an iterable of them is used as given, so an
empty iterable disables reporting whatever the environment says.
"""
if health is None:
reporters = from_environment()
elif isinstance(health, HealthReporter):
reporters = [health]
else:
reporters = list(health)
for reporter in reporters:
if not isinstance(reporter, HealthReporter):
raise TypeError(
f"health reporters must be sgn.health.HealthReporter instances, "
f"got {reporter!r}"
)
logger.info("reporting pipeline health to %r", reporter)
return cls(reporters)
|
Systemd
Bases: HealthReporter
flowchart TD
sgn.health.Systemd[Systemd]
sgn.health.HealthReporter[HealthReporter]
sgn.health.HealthReporter --> sgn.health.Systemd
click sgn.health.Systemd href "" "sgn.health.Systemd"
click sgn.health.HealthReporter href "" "sgn.health.HealthReporter"
Report to systemd over the sd_notify(3) notification socket.
ready sends READY=1, completing startup of a Type=notify
service. alive sends WATCHDOG=1, feeding WatchdogSec=: the
heartbeat only goes out when the graph strides, so a stride slower than
that timeout (including time a source blocks on input) gets the service
killed and restarted. stopping sends STOPPING=1; systemd then
disarms the watchdog and starts TimeoutStopSec=, as if it had sent
SIGTERM, so a process that reaches end-of-stream but cannot exit -- a
thread that will not join, say -- is killed rather than left hanging.
Each message carries a STATUS= line for systemctl status.
Parameters:
| Name |
Type |
Description |
Default |
socket_path
|
str
|
The notification socket as systemd passes it in NOTIFY_SOCKET:
an absolute filesystem path, or @name for a Linux abstract
socket.
|
required
|
heartbeat_interval
|
float
|
Seconds between WATCHDOG=1 messages. :meth:from_environment
derives this from WATCHDOG_USEC.
|
1.0
|
Source code in src/sgn/health.py
| class Systemd(HealthReporter):
"""Report to systemd over the sd_notify(3) notification socket.
``ready`` sends ``READY=1``, completing startup of a ``Type=notify``
service. ``alive`` sends ``WATCHDOG=1``, feeding ``WatchdogSec=``: the
heartbeat only goes out when the graph strides, so a stride slower than
that timeout (including time a source blocks on input) gets the service
killed and restarted. ``stopping`` sends ``STOPPING=1``; systemd then
disarms the watchdog and starts ``TimeoutStopSec=``, as if it had sent
SIGTERM, so a process that reaches end-of-stream but cannot exit -- a
thread that will not join, say -- is killed rather than left hanging.
Each message carries a ``STATUS=`` line for ``systemctl status``.
Args:
socket_path:
The notification socket as systemd passes it in ``NOTIFY_SOCKET``:
an absolute filesystem path, or ``@name`` for a Linux abstract
socket.
heartbeat_interval:
Seconds between ``WATCHDOG=1`` messages. :meth:`from_environment`
derives this from ``WATCHDOG_USEC``.
"""
def __init__(self, socket_path: str, heartbeat_interval: float = 1.0) -> None:
if not socket_path or socket_path[0] not in ("/", "@"):
raise ValueError(f"unsupported NOTIFY_SOCKET path {socket_path!r}")
self.socket_path = socket_path
# A leading '@' names an abstract socket, whose address is the name
# prefixed with a NUL byte.
if socket_path[0] == "@":
self._address = "\0" + socket_path[1:]
else:
self._address = socket_path
self.heartbeat_interval = heartbeat_interval
self._state: HealthState | None = None
@classmethod
def from_environment(
cls, environ: Mapping[str, str] | None = None
) -> Systemd | None:
"""Build from the variables systemd sets for a service, or ``None``.
Requires ``NOTIFY_SOCKET``. When ``WATCHDOG_USEC`` is also set
(systemd exports the ``WatchdogSec=`` timeout in microseconds),
heartbeats go out at half that interval, as sd_watchdog_enabled(3)
recommends; otherwise once per second.
"""
env = os.environ if environ is None else environ
socket_path = env.get("NOTIFY_SOCKET")
if not socket_path:
return None
heartbeat_interval = 1.0
usec = env.get("WATCHDOG_USEC")
if usec:
try:
heartbeat_interval = int(usec) / 2e6
except ValueError:
logger.warning("ignoring unparseable WATCHDOG_USEC=%r", usec)
return cls(socket_path, heartbeat_interval=heartbeat_interval)
def attach(self, state: HealthState) -> None:
self._state = state
def ready(self) -> None:
self._send("READY=1")
def alive(self) -> None:
self._send("WATCHDOG=1")
def stopping(self) -> None:
self._send("STOPPING=1")
def detach(self) -> None:
self._state = None
def _send(self, state_line: str) -> None:
message = state_line
if self._state is not None:
message += f"\nSTATUS={self._state.summary()}"
# A fresh socket per message, as the sd_notify(3) reference does; it
# is close-on-exec by default (PEP 446). Non-blocking, so a supervisor
# that stops draining its socket can never stall the event loop.
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
sock.settimeout(0)
sock.connect(self._address)
sock.send(message.encode())
def __repr__(self) -> str:
return (
f"{type(self).__name__}({self.socket_path!r}, "
f"heartbeat_interval={self.heartbeat_interval})"
)
|
from_environment(environ=None)
classmethod
Build from the variables systemd sets for a service, or None.
Requires NOTIFY_SOCKET. When WATCHDOG_USEC is also set
(systemd exports the WatchdogSec= timeout in microseconds),
heartbeats go out at half that interval, as sd_watchdog_enabled(3)
recommends; otherwise once per second.
Source code in src/sgn/health.py
| @classmethod
def from_environment(
cls, environ: Mapping[str, str] | None = None
) -> Systemd | None:
"""Build from the variables systemd sets for a service, or ``None``.
Requires ``NOTIFY_SOCKET``. When ``WATCHDOG_USEC`` is also set
(systemd exports the ``WatchdogSec=`` timeout in microseconds),
heartbeats go out at half that interval, as sd_watchdog_enabled(3)
recommends; otherwise once per second.
"""
env = os.environ if environ is None else environ
socket_path = env.get("NOTIFY_SOCKET")
if not socket_path:
return None
heartbeat_interval = 1.0
usec = env.get("WATCHDOG_USEC")
if usec:
try:
heartbeat_interval = int(usec) / 2e6
except ValueError:
logger.warning("ignoring unparseable WATCHDOG_USEC=%r", usec)
return cls(socket_path, heartbeat_interval=heartbeat_interval)
|
from_environment(environ=None)
The reporters the environment asks for; empty when it asks for none.
NOTIFY_SOCKET (set by systemd for Type=notify services) selects
:class:Systemd. SGN_HEALTH_PORT selects :class:HTTPHealth, with
SGN_HEALTH_HOST (default 0.0.0.0) and SGN_HEALTH_MAX_AGE
(seconds, default 30). Both may be set.
Source code in src/sgn/health.py
| def from_environment(environ: Mapping[str, str] | None = None) -> list[HealthReporter]:
"""The reporters the environment asks for; empty when it asks for none.
``NOTIFY_SOCKET`` (set by systemd for ``Type=notify`` services) selects
:class:`Systemd`. ``SGN_HEALTH_PORT`` selects :class:`HTTPHealth`, with
``SGN_HEALTH_HOST`` (default ``0.0.0.0``) and ``SGN_HEALTH_MAX_AGE``
(seconds, default 30). Both may be set.
"""
env = os.environ if environ is None else environ
reporters: list[HealthReporter] = []
systemd = Systemd.from_environment(env)
if systemd is not None:
reporters.append(systemd)
port = env.get("SGN_HEALTH_PORT")
if port:
max_age = env.get("SGN_HEALTH_MAX_AGE")
reporters.append(
HTTPHealth(
port=int(port),
host=env.get("SGN_HEALTH_HOST") or "0.0.0.0", # noqa: S104
max_age=float(max_age) if max_age else None,
)
)
return reporters
|