Skip to content

Commit d29f625

Browse files
committed
docs: add health-handler design spec
1 parent 77d6d6f commit d29f625

1 file changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Health handler
2+
3+
**Date:** 2026-05-20
4+
**Status:** Design — ready for implementation plan
5+
6+
## Problem
7+
8+
Services built on this library need a `/health` endpoint that reports
9+
overall health plus per-dependency status. The internal `hii-generate-health-java`
10+
library already provides the check-running machinery (`HealthCheckService`,
11+
`HealthCheck`, `HealthCheckResult`, `Status`) and a documented JSON shape:
12+
13+
```json
14+
{
15+
"outcome": "Up",
16+
"dependencies": [
17+
{ "id": "jdbc", "status": "Up" }
18+
]
19+
}
20+
```
21+
22+
We want a ready-to-use `HttpHandler` in this repo that produces that exact
23+
shape, **without** taking a compile-time dependency on the health library.
24+
Callers should be able to wire it up in one line.
25+
26+
## Goals
27+
28+
1. Add `Handlers.healthHandler(Supplier<HealthOutcome>)` that:
29+
- Accepts GET and HEAD only (405 otherwise, with `Allow: GET, HEAD`).
30+
- Returns `200 OK` with `Content-Type: application/json` when `outcome` is `Up`.
31+
- Returns `503 Service Unavailable` with the same body shape when `outcome` is `Down`.
32+
- Never propagates a probe failure as a 500 — a throwing `Supplier` yields
33+
`Down` + empty dependency list + 503.
34+
2. Define small public records `HealthOutcome` and `Dependency` in
35+
`com.retailsvc.http` that mirror the health library's data shape on the wire,
36+
so this repo stays decoupled from the library.
37+
3. Reuse existing infrastructure (`MethodLimitedHandler`, hand-rolled
38+
JSON rendering á la `ProblemDetailRenderer`). No new third-party deps.
39+
40+
## Non-goals
41+
42+
- Direct integration with `HealthCheckService` (callers adapt the library's
43+
`HealthCheckResult` to `HealthOutcome` in a one-line lambda; that adapter
44+
lives in the consuming service, not here).
45+
- Caching of probe results (the health library already supplies
46+
`CachingHealthCheck`).
47+
- A configurable wire format — the field names `outcome`, `dependencies`,
48+
`id`, `status` and the string values `Up` / `Down` are fixed to match the
49+
library's documented contract.
50+
- Configurable Content-Type or status codes — fixed at `application/json` +
51+
200/503.
52+
- An integration test — unit coverage is sufficient; `MethodLimitedHandler`
53+
itself is already integration-tested elsewhere.
54+
55+
## Design
56+
57+
### Public types — `com.retailsvc.http`
58+
59+
```java
60+
public record HealthOutcome(String outcome, List<Dependency> dependencies) {
61+
public HealthOutcome {
62+
Objects.requireNonNull(outcome, "outcome");
63+
dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of()));
64+
}
65+
66+
public boolean isUp() {
67+
return "Up".equalsIgnoreCase(outcome);
68+
}
69+
}
70+
71+
public record Dependency(String id, String status) {
72+
public Dependency {
73+
Objects.requireNonNull(id, "id");
74+
Objects.requireNonNull(status, "status");
75+
}
76+
}
77+
```
78+
79+
`HealthOutcome.isUp()` is case-insensitive so callers that pass through a
80+
library-style "Up" or a custom-cased "UP" both work.
81+
82+
### Public API — `Handlers.healthHandler`
83+
84+
```java
85+
public static HttpHandler healthHandler(Supplier<HealthOutcome> probe) {
86+
Objects.requireNonNull(probe, "probe");
87+
return new MethodLimitedHandler(exchange -> {
88+
try (exchange) {
89+
HealthOutcome outcome;
90+
try {
91+
outcome = probe.get();
92+
} catch (RuntimeException e) {
93+
LOG.warn("Health probe threw", e);
94+
outcome = new HealthOutcome("Down", List.of());
95+
}
96+
byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8);
97+
int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE;
98+
exchange.getResponseHeaders().add("Content-Type", "application/json");
99+
exchange.sendResponseHeaders(status, body.length);
100+
exchange.getResponseBody().write(body);
101+
}
102+
});
103+
}
104+
```
105+
106+
`HTTP_OK` and `HTTP_UNAVAILABLE` come from `java.net.HttpURLConnection` (per
107+
project convention — no magic numbers).
108+
109+
### Internal — `com.retailsvc.http.internal.HealthRenderer`
110+
111+
Package-private final class with a private constructor and a single
112+
`static String toJson(HealthOutcome)` method. Implementation mirrors
113+
`ProblemDetailRenderer`: hand-rolled `StringBuilder`, manual JSON-string
114+
escaping for `\\`, `\"`, `\n`, `\r`, `\t`, `\b`, `\f`, and `\uXXXX` for any
115+
remaining control characters below `0x20`.
116+
117+
### Caller-side wiring (illustrative, not part of this repo)
118+
119+
```java
120+
HealthCheckService service = new HealthCheckService();
121+
// register checks…
122+
123+
server = OpenApiServer.builder()
124+
.spec(spec)
125+
.jsonMapper(mapper)
126+
.handlers(operationHandlers)
127+
.addHandler("/health", Handlers.healthHandler(() -> {
128+
HealthCheckResult r = service.check();
129+
return new HealthOutcome(
130+
r.outcome(),
131+
r.dependencies().stream()
132+
.map(s -> new Dependency(s.id(), s.status()))
133+
.toList());
134+
}))
135+
.build();
136+
```
137+
138+
The adapter lambda is the only place that knows about both libraries — which
139+
is exactly where the coupling belongs.
140+
141+
## Error handling
142+
143+
- `Supplier` returns `null`: `Objects.requireNonNull` inside the handler
144+
produces an NPE; this falls outside the `RuntimeException` catch and
145+
propagates up through `ExceptionFilter` (yielding a 500). Probes are
146+
expected to return a value; treating a `null` return as a programming
147+
error is intentional.
148+
- `Supplier` throws `RuntimeException`: caught; logged at `warn`; rendered
149+
as `Down` with empty dependency list and 503.
150+
- IOException while writing the response: not caught here; `ExceptionFilter`
151+
handles it.
152+
153+
## Testing
154+
155+
Unit tests only (Surefire). New file `HealthHandlerTest` (or extension of
156+
`HandlersTest`):
157+
158+
- GET, `Up` outcome with dependencies → 200, `application/json`, body
159+
equals expected JSON (parsed back via Jackson in test scope, asserted
160+
field by field).
161+
- GET, `Up` outcome with empty dependency list → 200, body has empty
162+
array.
163+
- GET, `Down` outcome → 503, body still rendered.
164+
- HEAD → status code only, no body bytes.
165+
- POST → 405 with `Allow: GET, HEAD` header.
166+
- Probe throws `RuntimeException` → 503, body `{"outcome":"Down","dependencies":[]}`.
167+
- Probe returns `null` → propagates (assertion: 500 via the default
168+
exception handler).
169+
170+
New file `HealthRendererTest`:
171+
172+
- Round-trip outcomes through Jackson to confirm valid JSON.
173+
- Strings containing `"`, `\`, newline, tab, control char `` are
174+
escaped correctly.
175+
176+
Records `HealthOutcome` and `Dependency` get tiny tests for null/empty
177+
argument validation and (`HealthOutcome` only) `isUp()` case-insensitivity.
178+
179+
## Out of scope / future
180+
181+
- Wiring the handler into `ServerLauncher` (the example launcher) — not
182+
needed; the launcher exists for local development of the OpenAPI flow.
183+
- A second `healthHandler` overload that takes a `Callable` or
184+
`CompletionStage` — no concrete need yet.
185+
- An integration test that exercises the handler through `OpenApiServer`
186+
end-to-end.

0 commit comments

Comments
 (0)