Skip to content

Commit 6317b8f

Browse files
committed
docs: describe the health endpoints the daemon actually serves
API.md and PROJECT_INDEX.md documented GET /health/liveness and /health/readiness. Neither exists: the daemon serves /health, /healthz, /ready and /live. Both response shapes were wrong too. The OpenAPI description of /health did not match the served body either. It named uptime instead of uptimeSeconds, described checks as booleans where each is an object, omitted timestamp and system, and documented a 503 that /health never returns -- it always answers 200 and puts the verdict in the body. All four endpoints are now specified against shared HealthResponse, HealthCheck and SystemInfo schemas. Also corrected: the scheduler check is no longer a stub, so it reports degraded and names the jobs it refused; NewHealthChecker takes the scheduler and the build's version rather than a hardcoded "1.0.0"; the checker has a Stop; and waiting on ShutdownChan is not waiting for shutdown to finish -- that is Done. durationMs carries nanoseconds, not milliseconds: it serialises a Go time.Duration. Stated wherever the field appears. Renaming the field would break every existing consumer, so it stays. Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
1 parent fd38b3b commit 6317b8f

7 files changed

Lines changed: 255 additions & 58 deletions

File tree

docs/API.md

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -315,34 +315,91 @@ POST /api/scheduler/reload
315315

316316
## Monitoring
317317

318+
Four endpoints, all unauthenticated:
319+
320+
| Endpoint | Body | Status code |
321+
|---|---|---|
322+
| `GET /health` | full report | always `200` |
323+
| `GET /healthz` | full report (alias) | always `200` |
324+
| `GET /ready` | full report | `503` when `unhealthy`, else `200` |
325+
| `GET /live` | `OK` as plain text | always `200` |
326+
318327
### Health Check
319328
```http
320-
GET /health/liveness
329+
GET /health
321330
```
322331

332+
`/health` answers `200` even while reporting a problem — the verdict is in the
333+
body, so a monitoring system can read *why*. Point a probe that must fail at
334+
`/ready` instead.
335+
323336
**Response:**
324337
```json
325338
{
326-
"status": "healthy",
327-
"timestamp": "2024-01-01T12:00:00Z"
339+
"status": "degraded",
340+
"timestamp": "2026-08-02T16:55:52Z",
341+
"uptimeSeconds": 41.2,
342+
"version": "v0.28.1",
343+
"checks": {
344+
"docker": {
345+
"name": "docker",
346+
"status": "healthy",
347+
"message": "Docker 29.6.2 running with 25 containers",
348+
"lastChecked": "2026-08-02T16:55:52Z",
349+
"durationMs": 8332586
350+
},
351+
"scheduler": {
352+
"name": "scheduler",
353+
"status": "degraded",
354+
"message": "1 configured job(s) are not scheduled and will not run: broken",
355+
"lastChecked": "2026-08-02T16:55:52Z",
356+
"durationMs": 3180
357+
},
358+
"system": {
359+
"name": "system",
360+
"status": "healthy",
361+
"message": "System resources normal",
362+
"lastChecked": "2026-08-02T16:55:52Z",
363+
"durationMs": 553638
364+
}
365+
},
366+
"system": {
367+
"goVersion": "go1.26.0",
368+
"goroutines": 24,
369+
"cpus": 8,
370+
"memoryAllocBytes": 3512976,
371+
"memoryTotalBytes": 12730376,
372+
"gcRuns": 1
373+
}
328374
}
329375
```
330376

377+
`status` is the worst status among the checks. `version` is the running build,
378+
or `dev` when built without ldflags. Note that `durationMs` carries
379+
**nanoseconds** despite its name — it serialises a Go `time.Duration`.
380+
381+
The `scheduler` check reports `degraded` and names every configured job the
382+
scheduler refused, so a job that never fires — an unparsable schedule, a
383+
duplicate name — is visible to monitoring rather than only in the startup log.
384+
331385
### Readiness Check
332386
```http
333-
GET /health/readiness
387+
GET /ready
334388
```
335389

336-
**Response:**
337-
```json
338-
{
339-
"ready": true,
340-
"scheduler": "running",
341-
"docker": "connected",
342-
"database": "n/a"
343-
}
390+
Same body as `/health`. The status code carries the verdict: `503` when the
391+
overall status is `unhealthy`, `200` otherwise. `degraded` deliberately stays
392+
`200` — one job with a typo should not take a daemon out of rotation while its
393+
other jobs keep running.
394+
395+
### Liveness Check
396+
```http
397+
GET /live
344398
```
345399

400+
Answers `200` with the plain-text body `OK` as long as the process serves HTTP.
401+
It runs no checks, so it never reports on Docker or the scheduler.
402+
346403
### Prometheus Metrics
347404
```http
348405
GET /metrics

docs/PROJECT_INDEX.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,9 @@ ofelia_circuit_breaker_* # Circuit breaker states
131131
```
132132

133133
### Health Endpoints
134-
- `/health/liveness`: Service availability
135-
- `/health/readiness`: Service readiness
134+
- `/health`, `/healthz`: Full health report, always 200 — the verdict is in the body
135+
- `/ready`: Same report, 503 when the overall status is `unhealthy`
136+
- `/live`: Plain-text `OK` as long as the process serves HTTP, runs no checks
136137
- `/metrics`: Prometheus metrics endpoint
137138

138139
## 🔌 API Reference

docs/QUICK_REFERENCE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,10 @@ GET /api/jobs/removed # Removed jobs
319319

320320
# Config & Health
321321
GET /api/config # Current configuration
322-
GET /health # Health check
322+
GET /health # Full report, always 200 - verdict is in the body
323+
GET /healthz # Alias of /health
324+
GET /ready # Same report, 503 when unhealthy
325+
GET /live # Plain-text OK, runs no checks
323326
GET /metrics # Prometheus metrics
324327
```
325328

docs/TROUBLESHOOTING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,9 @@ SDK provider failed to connect to Docker: pinging docker: context deadline excee
438438
Docker API version negotiation timed out; continuing with default API version
439439
```
440440

441-
`/health` returns non-2xx within ~5 seconds; `/ready` reports `unhealthy` for the `docker` check.
441+
Within ~5 seconds the `docker` check turns `unhealthy`, which makes the overall
442+
status `unhealthy` and `/ready` answer **503**. `/health` still answers 200 —
443+
it always does — so read its body rather than its status code.
442444

443445
`ofelia doctor` reports each Docker call individually (Ping ~5s, `HasImageLocally` ~5s per image).
444446

docs/openapi.yaml

Lines changed: 141 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -116,53 +116,79 @@ paths:
116116
117117
/health:
118118
get:
119-
summary: Health check endpoint
119+
summary: Detailed health report
120+
description: >-
121+
Always answers 200, including while the report says `degraded` or
122+
`unhealthy` — the status is in the body, not the status code, so a
123+
monitoring system can read *why* rather than only that something is
124+
wrong. Use /ready for a probe that fails the request.
120125
tags:
121126
- Monitoring
122127
security: []
123128
responses:
124129
'200':
125-
description: Service is healthy
130+
description: The current report, whatever its status
126131
content:
127132
application/json:
128133
schema:
129-
type: object
130-
properties:
131-
status:
132-
type: string
133-
enum: [healthy, degraded, unhealthy]
134-
example: healthy
135-
uptime:
136-
type: number
137-
description: Uptime in seconds
138-
example: 3600
139-
version:
140-
type: string
141-
example: "1.1.0"
142-
checks:
143-
type: object
144-
properties:
145-
docker:
146-
type: boolean
147-
example: true
148-
scheduler:
149-
type: boolean
150-
example: true
134+
$ref: '#/components/schemas/HealthResponse'
135+
136+
/healthz:
137+
get:
138+
summary: Detailed health report (alias of /health)
139+
tags:
140+
- Monitoring
141+
security: []
142+
responses:
143+
'200':
144+
description: The current report, whatever its status
145+
content:
146+
application/json:
147+
schema:
148+
$ref: '#/components/schemas/HealthResponse'
149+
150+
/ready:
151+
get:
152+
summary: Readiness probe
153+
description: >-
154+
Same body as /health, but the status code carries the verdict: 503 when
155+
the overall status is `unhealthy`, 200 otherwise. `degraded` stays 200
156+
on purpose — one job with an unparsable schedule should not take the
157+
whole daemon out of rotation while its other jobs keep running.
158+
tags:
159+
- Monitoring
160+
security: []
161+
responses:
162+
'200':
163+
description: Ready — overall status is `healthy` or `degraded`
164+
content:
165+
application/json:
166+
schema:
167+
$ref: '#/components/schemas/HealthResponse'
151168
'503':
152-
description: Service unhealthy
169+
description: Not ready — overall status is `unhealthy`
153170
content:
154171
application/json:
155172
schema:
156-
type: object
157-
properties:
158-
status:
159-
type: string
160-
example: unhealthy
161-
errors:
162-
type: array
163-
items:
164-
type: string
165-
example: ["Docker connection failed"]
173+
$ref: '#/components/schemas/HealthResponse'
174+
175+
/live:
176+
get:
177+
summary: Liveness probe
178+
description: >-
179+
Answers as long as the process serves HTTP at all. It runs no checks,
180+
so it never reports on Docker or the scheduler.
181+
tags:
182+
- Monitoring
183+
security: []
184+
responses:
185+
'200':
186+
description: The process is running
187+
content:
188+
text/plain:
189+
schema:
190+
type: string
191+
example: OK
166192

167193
/jobs:
168194
get:
@@ -327,6 +353,86 @@ components:
327353
description: Session cookie authentication
328354

329355
schemas:
356+
HealthCheck:
357+
type: object
358+
description: One individual check within the health report.
359+
properties:
360+
name:
361+
type: string
362+
example: scheduler
363+
status:
364+
type: string
365+
enum: [healthy, degraded, unhealthy]
366+
example: degraded
367+
message:
368+
type: string
369+
description: Omitted when empty.
370+
example: "1 configured job(s) are not scheduled and will not run: broken"
371+
lastChecked:
372+
type: string
373+
format: date-time
374+
durationMs:
375+
type: integer
376+
format: int64
377+
description: >-
378+
How long the check took, in **nanoseconds** despite the field name —
379+
it serialises a Go time.Duration, which marshals as nanoseconds. A
380+
local Docker ping reports roughly 8000000 here, i.e. 8 ms.
381+
example: 8332586
382+
required: [name, status, lastChecked, durationMs]
383+
384+
HealthResponse:
385+
type: object
386+
description: >-
387+
The body served by /health, /healthz and /ready. `status` is the worst
388+
status among `checks`: any unhealthy check makes the whole report
389+
unhealthy, otherwise any degraded check makes it degraded.
390+
properties:
391+
status:
392+
type: string
393+
enum: [healthy, degraded, unhealthy]
394+
example: degraded
395+
timestamp:
396+
type: string
397+
format: date-time
398+
uptimeSeconds:
399+
type: number
400+
example: 3600
401+
version:
402+
type: string
403+
description: >-
404+
The running build's version, or `dev` when built without ldflags.
405+
example: v0.28.1
406+
checks:
407+
type: object
408+
description: Keyed by check name — `docker`, `scheduler` and `system`.
409+
additionalProperties:
410+
$ref: '#/components/schemas/HealthCheck'
411+
system:
412+
$ref: '#/components/schemas/SystemInfo'
413+
required: [status, timestamp, uptimeSeconds, version, checks, system]
414+
415+
SystemInfo:
416+
type: object
417+
properties:
418+
goVersion:
419+
type: string
420+
example: go1.26.0
421+
goroutines:
422+
type: integer
423+
example: 24
424+
cpus:
425+
type: integer
426+
example: 8
427+
memoryAllocBytes:
428+
type: integer
429+
format: int64
430+
memoryTotalBytes:
431+
type: integer
432+
format: int64
433+
gcRuns:
434+
type: integer
435+
330436
Job:
331437
type: object
332438
properties:

docs/packages/core.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@ type Scheduler struct {
130130
- `Start()`: Begin scheduling
131131
- `Stop()`: Graceful shutdown
132132
- `RunJob()`: Manual job trigger
133+
- `GetUnschedulableJobs()`: Jobs the scheduler refused, keyed by name with the
134+
reason. A job whose schedule will not parse is configured but never runs, so
135+
the health check reports it instead of leaving it in a startup log line. A
136+
name that later registers clears its own entry, as does removing the job, so
137+
a corrected config reloaded at runtime recovers without a restart. The
138+
returned map is a copy.
139+
140+
**Shutdown:** `ShutdownManager` runs hooks in priority groups, lowest first,
141+
each group completing before the next starts.
142+
143+
- `ShutdownChan()` closes when shutdown **starts** — before the first hook runs.
144+
- `Done()` closes when every group has finished, including the path where a
145+
hook overruns the timeout.
146+
147+
Anything that ends the process must wait on `Done()`. Waiting on
148+
`ShutdownChan()` ends it while the hooks are still running, which leaves every
149+
group after the first killed in flight.
133150

134151
### Context
135152

0 commit comments

Comments
 (0)