Knowing When Things Break (Before Users Tell You)¶
Here's how you find out your PaaS is broken: a user emails saying "the app is down." You check the logs. The database connection pool exhausted itself three hours ago. The app has been returning 500 errors ever since. Nobody noticed.
Hop3's health checks exist to shorten that gap. The goal is one command that tells you whether the platform underneath your apps is actually working.
The Dashboard on Your Terminal¶
$ hop3 system status
Hop3 server: hop3-dev (135.181.203.156) — v0.5.0.dev3 — up 14d 3h
Services
Hop3 Server ✓ running
Nginx ✓ running
uWSGI Emperor ✓ running
Backing services
PostgreSQL ✓ ok
MySQL ✓ ok
Redis ⚠ unreachable — connection refused (127.0.0.1:6379)
S3 (minio) ✓ ok
Filesystem
HOP3_ROOT ✓ writable
Apps directory ✓ writable
Disk usage ⚠ 86%
Certificates
SSL ⚠ self-signed (Let's Encrypt not configured)
Status: ⚠ 2 warnings
One glance tells you what's healthy, what's degraded, and what's broken. The identity header up top — host, IP, version, uptime — answers "which server am I even looking at?" before the checks answer "is it OK?".
The severity legend is ✓ ok, ⚠ warning, ✗ failure. Optional services like Redis report ⚠ when unreachable rather than ✗, so a server that doesn't use Redis reads as degraded rather than failed. The bottom-line summary rolls everything up to the worst severity seen.
Each Service Knows Its Own Health¶
Here's the design decision worth dwelling on: there's no monolithic health checker that knows about everything. Each plugin contributes its own health check, discovered through the get_health_checks() hook.
The PostgreSQL addon knows how to verify PostgreSQL is working:
class PostgresHealthCheck:
name = "postgresql"
def is_configured(self) -> bool:
# Skip the check entirely when this server has no PostgreSQL.
admin = PostgresAdmin.from_config()
return admin.superuser_password is not None
def check(self) -> HealthCheckResult:
try:
admin = PostgresAdmin.from_config()
conn = psycopg2.connect(**admin.get_connection_params())
conn.close()
return HealthCheckResult(
name="PostgreSQL",
passed=True,
message="ok",
)
except Exception as e:
return HealthCheckResult(
name="PostgreSQL",
passed=False,
message=f"connection failed: {e}",
)
When we add a new addon — say, MongoDB — it ships with its own health check. There's no central list to update: the plugin system handles discovery. system status runs every registered check and renders the results.
A Simple Protocol¶
Every health check satisfies the same small interface:
@dataclass
class HealthCheckResult:
name: str
passed: bool
message: str
details: dict[str, Any] = field(default_factory=dict)
severity: Severity | None = None # "ok" | "warn" | "fail"
class HealthCheck(Protocol):
name: str
def is_configured(self) -> bool: ...
def check(self) -> HealthCheckResult: ...
A check reports passed, and severity is derived from it — passed=True renders as ok, passed=False as fail. The one nuance worth its own field: a check can set severity explicitly to override that default. An optional service that's unreachable returns passed=False but severity="warn" — the result is unacceptable from the check's point of view, yet the operator can still ship. That's how Redis shows up as a yellow warning instead of a red failure.
is_configured() is the other half of keeping the report accurate: a check that doesn't apply to this server says so, and is skipped rather than reported as a spurious failure.
Reporting at Startup¶
The same addon checks run when the server starts. If PostgreSQL isn't accepting connections, or Redis is configured but unreachable, the failure is logged with a pointed message — apps using this service will fail to deploy — so the problem surfaces in the logs before the first deploy hits it, rather than after.
def verify_addon_health() -> dict[str, HealthCheckResult]:
results = {}
for check in get_all_health_checks():
result = run_health_check(check)
results[check.name] = result
if not result.passed:
logger.warning(
"%s health check failed: %s. "
"Apps using this service will fail to deploy.",
result.name, result.message,
)
return results
Your Apps Get a Startup Probe Too¶
Platform health is one thing; what about the applications running on Hop3? An app can declare a health endpoint in hop3.toml:
At deploy time, Hop3 probes that path before declaring the app up. If /health doesn't return a 200 within the window, the deploy doesn't quietly succeed — it fails loudly, with the app reported as not having responded to health checks. That's the difference between "deployed" meaning the process started and "deployed" meaning the app actually answers requests.
What "healthy" means is up to you. A trivial endpoint:
A better one that checks the dependencies your app can't run without:
@app.route("/health")
def health():
try:
db.session.execute("SELECT 1")
redis_client.ping()
return "OK", 200
except Exception as e:
return f"Unhealthy: {e}", 503
The second version is the one that catches the connection-pool scenario from the opening paragraph — at deploy time, before you route traffic to a broken release.
Machine-Readable Output¶
For automation, system status speaks JSON:
This emits the same identity, per-section items, and overall severity as a structured document — feed it to a dashboard or an external monitor. And for the shell-script case, the command sets its exit code from the worst severity it found:
Zero means everything's OK; non-zero means there's at least a warning. --quiet collapses the report to a single line, which is all a cron job or CI gate needs.
Where This Is Going¶
The checks above are the foundation. The direction we're building toward — designed in ADR 029, not yet shipped — turns this from a tool you run into a platform that watches itself:
- Continuous reconciliation. A background watchdog that periodically compares each app's recorded state against its actual process state, so a process that dies overnight is detected in seconds rather than when a user complains. Hop3 already has the state-sync primitive (
App.sync_state()); today it runs when you view the dashboard or issue a lifecycle command, not on a timer. - Restart policies. Per-app
on_failure/always/neverpolicies, with exponential backoff and a cap, so transient crashes recover automatically without flapping forever. - An event log. An immutable audit trail of state changes, health results, and restarts — the history you wish you had when something broke at 3 AM.
- Certificate-expiry monitoring. Today the certificate check reports whether a real certificate is configured at all (self-signed vs. Let's Encrypt). Tracking days-until-expiry — and warning before a renewal silently fails — is the natural next step.
We're calling those out explicitly because the gap between "designed" and "running" is exactly the kind of thing health checks are supposed to make visible. We'd rather be clear about which is which.
Try It Now¶
Check your Hop3 server:
Add a health endpoint to your app:
Then build a /health that actually checks your dependencies, rather than one that only returns 200.
Health checks aren't glamorous, but they're the difference between "we noticed and fixed it" and "a customer told us it was down for three hours." Build the observability in from the start.
For more on operating Hop3, see the Administration Guide.