Metadata-Version: 2.5
Name: exobrain-health
Version: 0.1.0
Summary: Heartbeat-based HTTP health probe for MyExobrain background workers
Author-email: Laurent LAPORTE <laurent.laporte.pro@gmail.com>
License-File: LICENSE.md
Requires-Python: <3.14,>=3.12
Requires-Dist: fastapi>=0.115.0
Requires-Dist: uvicorn>=0.32.0
Description-Content-Type: text/markdown

# exobrain-health

Heartbeat-based HTTP health probe for MyExobrain background workers
(`exobrain-orchestrator`, `exobrain-actions`, `exobrain-deployment-risk`).

Azure Container Apps only supports HTTP(S) and TCP probes. A TCP probe can't detect a worker
whose main loop is stuck (the kernel still accepts connections), so each worker exposes
`GET /health` on an internal port, backed by a heartbeat written by its main loop.

## How it works

```
┌──────────── worker process ──────────────┐
│ main thread: worker loop                 │──► state.heartbeat() on every iteration
│                                          │
│ daemon thread: uvicorn + FastAPI         │──► GET /health reads state.snapshot()
└──────────────────────────────────────────┘
```

- The endpoint makes no network, Redis or database call: it reads the in-memory `HealthState`
  shared by reference with the worker loop (both run in the same process).
- `status` is computed from the age of the last heartbeat (monotonic clock):

| Status     | HTTP | Condition                                         |
|------------|------|---------------------------------------------------|
| `starting` | 503  | no heartbeat yet (worker still initializing)      |
| `ok`       | 200  | last heartbeat age ≤ `max_heartbeat_age`          |
| `stale`    | 503  | last heartbeat age > `max_heartbeat_age` (stuck)  |

Example response:

```json
{"status": "ok", "last_heartbeat_age_s": 0.42, "max_heartbeat_age_s": 60.0, "uptime_s": 3600.1, "version": "5.6.0"}
```

## Usage

`exobrain.health` is a PEP 420 namespace package:

```python
from exobrain.health import HealthServer, HealthSettings, HealthState

settings = HealthSettings.from_env(default_max_heartbeat_age=60)
state = HealthState(settings.max_heartbeat_age)
if settings.enabled:
    HealthServer(state, port=settings.port, version="1.0.0").start()

while not should_stop:
    state.heartbeat()
    ...  # one iteration of the worker loop
```

A service that already runs a FastAPI application can mount `create_health_router(state)` instead.

## Environment variables

| Variable                   | Default            | Description                                       |
|----------------------------|--------------------|---------------------------------------------------|
| `HEALTH_ENABLED`           | `true`             | Start the health server                           |
| `HEALTH_PORT`              | `8080`             | Listening port                                    |
| `HEALTH_MAX_HEARTBEAT_AGE` | caller default     | Seconds without heartbeat before `/health` fails  |

## Azure Container Apps probes

No ingress is needed: probes reach the container port directly.

| Probe    | Path      | Port | Period | Failure threshold |
|----------|-----------|------|--------|-------------------|
| Startup  | `/health` | 8080 | 10 s   | 30                |
| Liveness | `/health` | 8080 | 20 s   | 3                 |

## Development

Install [uv](https://docs.astral.sh/uv/) and [Hatch](https://hatch.pypa.io/) (once, as a uv tool),
then the development environment:

```bash
uv tool install hatch
uv sync
```

Before committing, run the tests and the quality checks with Hatch. Use `--fix` with `code` and
`fmt` to let Ruff fix the issues instead of only reporting them:

```bash
uv run pytest                 # unit tests
hatch check code --fix        # lint (Ruff, settings from [tool.ruff])
hatch check fmt --fix         # format (Ruff)
hatch check types             # type check (mypy, strict)
hatch check                   # all the checks, without fixing
```

The `build` workflow runs the unit tests and `hatch check` on every push to `main` and `development`.
On pull requests, only the issues located on changed lines are reported (as annotations) and fail
the build.

Pushing a `vX.Y.Z` tag matching the project version builds the package and publishes it to PyPI
(`publish` workflow).

## License

Private project, all rights reserved: see [LICENSE.md](LICENSE.md).
