Skip to content

Commit 35ec1be

Browse files
authored
feat: Health handling (#70)
1 parent 7d99cea commit 35ec1be

8 files changed

Lines changed: 1170 additions & 0 deletions

File tree

docs/superpowers/plans/2026-05-20-health-handler.md

Lines changed: 687 additions & 0 deletions
Large diffs are not rendered by default.
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 expected wire format is:
10+
11+
```json
12+
{
13+
"outcome": "Up",
14+
"dependencies": [
15+
{ "id": "jdbc", "status": "Up" }
16+
]
17+
}
18+
```
19+
20+
We want a ready-to-use `HttpHandler` in this repo that produces that exact
21+
shape. The handler must not depend on any specific health-check provider —
22+
callers supply the data through a `Supplier`, so they can plug in whatever
23+
mechanism (off-the-shelf or in-house) computes their dependency statuses.
24+
25+
## Goals
26+
27+
1. Add `Handlers.healthHandler(Supplier<HealthOutcome>)` that:
28+
- Accepts GET and HEAD only (405 otherwise, with `Allow: GET, HEAD`).
29+
- Returns `200 OK` with `Content-Type: application/json` when `outcome` is `Up`.
30+
- Returns `503 Service Unavailable` with the same body shape when `outcome` is `Down`.
31+
- Never propagates a probe failure as a 500 — a throwing `Supplier` yields
32+
`Down` + empty dependency list + 503.
33+
2. Define small public records `HealthOutcome` and `Dependency` in
34+
`com.retailsvc.http` that own the wire shape, so this library has no
35+
runtime or compile-time dependency on any specific health-check provider.
36+
3. Reuse existing infrastructure (`MethodLimitedHandler`, hand-rolled
37+
JSON rendering á la `ProblemDetailRenderer`). No new third-party deps.
38+
39+
## Non-goals
40+
41+
- Bundling or running health checks. Callers compute their own outcome
42+
(typically by adapting whatever check-runner they use into a
43+
`HealthOutcome`) and pass it in via the `Supplier`.
44+
- Caching of probe results. If a caller's checks are expensive, they
45+
memoize on their side of the `Supplier`.
46+
- A configurable wire format — the field names `outcome`, `dependencies`,
47+
`id`, `status` and the string values `Up` / `Down` are fixed.
48+
- Configurable Content-Type or status codes — fixed at `application/json` +
49+
200/503.
50+
- An integration test — unit coverage is sufficient; `MethodLimitedHandler`
51+
itself is already integration-tested elsewhere.
52+
53+
## Design
54+
55+
### Public types — `com.retailsvc.http`
56+
57+
```java
58+
public record HealthOutcome(String outcome, List<Dependency> dependencies) {
59+
public HealthOutcome {
60+
Objects.requireNonNull(outcome, "outcome");
61+
dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of()));
62+
}
63+
64+
public boolean isUp() {
65+
return "Up".equalsIgnoreCase(outcome);
66+
}
67+
}
68+
69+
public record Dependency(String id, String status) {
70+
public Dependency {
71+
Objects.requireNonNull(id, "id");
72+
Objects.requireNonNull(status, "status");
73+
}
74+
}
75+
```
76+
77+
`HealthOutcome.isUp()` is case-insensitive so callers that pass `"Up"`,
78+
`"UP"`, or `"up"` all map to a healthy 200 response.
79+
80+
### Public API — `Handlers.healthHandler`
81+
82+
```java
83+
public static HttpHandler healthHandler(Supplier<HealthOutcome> probe) {
84+
Objects.requireNonNull(probe, "probe");
85+
return new MethodLimitedHandler(exchange -> {
86+
try (exchange) {
87+
HealthOutcome outcome;
88+
try {
89+
outcome = probe.get();
90+
} catch (RuntimeException e) {
91+
LOG.warn("Health probe threw", e);
92+
outcome = new HealthOutcome("Down", List.of());
93+
}
94+
byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8);
95+
int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE;
96+
exchange.getResponseHeaders().add("Content-Type", "application/json");
97+
exchange.sendResponseHeaders(status, body.length);
98+
exchange.getResponseBody().write(body);
99+
}
100+
});
101+
}
102+
```
103+
104+
`HTTP_OK` and `HTTP_UNAVAILABLE` come from `java.net.HttpURLConnection` (per
105+
project convention — no magic numbers).
106+
107+
### Internal — `com.retailsvc.http.internal.HealthRenderer`
108+
109+
Package-private final class with a private constructor and a single
110+
`static String toJson(HealthOutcome)` method. Implementation mirrors
111+
`ProblemDetailRenderer`: hand-rolled `StringBuilder`, manual JSON-string
112+
escaping for `\\`, `\"`, `\n`, `\r`, `\t`, `\b`, `\f`, and `\uXXXX` for any
113+
remaining control characters below `0x20`.
114+
115+
### Caller-side wiring (illustrative, not part of this repo)
116+
117+
```java
118+
server = OpenApiServer.builder()
119+
.spec(spec)
120+
.jsonMapper(mapper)
121+
.handlers(operationHandlers)
122+
.addHandler("/health", Handlers.healthHandler(() -> {
123+
// Caller computes per-dependency statuses however they choose
124+
// and adapts the result into HealthOutcome / Dependency.
125+
List<Dependency> deps = List.of(
126+
new Dependency("jdbc", checkDatabase() ? "Up" : "Down"),
127+
new Dependency("cache", checkCache() ? "Up" : "Down"));
128+
String outcome = deps.stream().allMatch(d -> "Up".equalsIgnoreCase(d.status()))
129+
? "Up" : "Down";
130+
return new HealthOutcome(outcome, deps);
131+
}))
132+
.build();
133+
```
134+
135+
The `Supplier` is the only place that knows how to compute health, which is
136+
exactly where any third-party integration belongs.
137+
138+
## Error handling
139+
140+
Health endpoints should never 500 — load balancers and orchestrators interpret
141+
5xx-from-health-probe as "treat instance as unhealthy" only some of the time;
142+
a 503 with a `Down` body is the unambiguous signal. The handler therefore
143+
funnels every probe failure into the same `Down`+503 path:
144+
145+
- `Supplier` throws `RuntimeException`: caught; logged at `warn`; rendered
146+
as `Down` with empty dependency list and 503.
147+
- `Supplier` returns `null`: treated identically to a throwing probe —
148+
logged at `warn` and rendered as `Down`+503. (The handler asserts
149+
non-null via an explicit check inside the same `try` block.)
150+
- IOException while writing the response: not caught here; `ExceptionFilter`
151+
handles it (this is a transport-level failure, not a probe failure).
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` → 503, body `{"outcome":"Down","dependencies":[]}`
168+
(same behaviour as a throwing probe).
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.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.retailsvc.http;
2+
3+
import java.util.Objects;
4+
5+
/**
6+
* A single dependency entry within a {@link HealthOutcome}.
7+
*
8+
* <p>The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} for the
9+
* {@code status} field.
10+
*
11+
* @param id stable identifier of the dependency (e.g. {@code "jdbc"})
12+
* @param up whether the dependency is healthy
13+
*/
14+
public record Dependency(String id, boolean up) {
15+
public Dependency {
16+
Objects.requireNonNull(id, "id");
17+
}
18+
}

src/main/java/com/retailsvc/http/Handlers.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@
55
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
66
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
77
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
8+
import static java.net.HttpURLConnection.HTTP_OK;
9+
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
810
import static java.nio.charset.StandardCharsets.UTF_8;
911

1012
import com.retailsvc.http.internal.ClasspathResourceHandler;
1113
import com.retailsvc.http.internal.MethodLimitedHandler;
1214
import com.retailsvc.http.internal.ProblemDetailRenderer;
1315
import com.sun.net.httpserver.HttpHandler;
1416
import java.io.IOException;
17+
import java.util.List;
18+
import java.util.Objects;
19+
import java.util.function.Supplier;
1520
import java.util.stream.Collectors;
1621
import org.slf4j.Logger;
1722
import org.slf4j.LoggerFactory;
@@ -67,6 +72,67 @@ public static HttpHandler aliveHandler() {
6772
});
6873
}
6974

75+
/**
76+
* Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body
77+
* when the supplied probe reports {@code up == true}, and 503 with the same body shape otherwise.
78+
* A probe that throws a {@link RuntimeException} or returns {@code null} is mapped to a {@code
79+
* Down} outcome with an empty dependency list (and 503); the failure is never propagated to the
80+
* default exception handler.
81+
*
82+
* <p>The wire shape is
83+
*
84+
* <pre>{@code
85+
* {"outcome":"Up","dependencies":[{"id":"jdbc","status":"Up"}]}
86+
* }</pre>
87+
*
88+
* <p>Serialisation is delegated to the supplied {@code jsonMapper} — typically the same {@link
89+
* TypeMapper} the caller registered for {@code application/json} on the server. The handler hands
90+
* the mapper a record-shaped DTO with the components in the order shown above; any standard JSON
91+
* library (Gson, Jackson, …) serialises it identically.
92+
*
93+
* @param jsonMapper used to encode the wire-shape DTO to bytes
94+
* @param probe supplier of the current {@link HealthOutcome}
95+
*/
96+
public static HttpHandler healthHandler(TypeMapper jsonMapper, Supplier<HealthOutcome> probe) {
97+
Objects.requireNonNull(jsonMapper, "jsonMapper");
98+
Objects.requireNonNull(probe, "probe");
99+
return new MethodLimitedHandler(
100+
exchange -> {
101+
try (exchange) {
102+
HealthOutcome outcome;
103+
try {
104+
outcome = Objects.requireNonNull(probe.get(), "Health probe returned null");
105+
} catch (RuntimeException e) {
106+
LOG.warn("Health probe failed", e);
107+
outcome = new HealthOutcome(false, List.of());
108+
}
109+
byte[] body = jsonMapper.writeTo(toWireShape(outcome));
110+
int status = outcome.up() ? HTTP_OK : HTTP_UNAVAILABLE;
111+
exchange.getResponseHeaders().add("Content-Type", "application/json");
112+
exchange.sendResponseHeaders(status, body.length);
113+
exchange.getResponseBody().write(body);
114+
}
115+
});
116+
}
117+
118+
private static HealthBody toWireShape(HealthOutcome outcome) {
119+
return new HealthBody(
120+
label(outcome.up()),
121+
outcome.dependencies().stream()
122+
.map(d -> new DependencyBody(d.id(), label(d.up())))
123+
.toList());
124+
}
125+
126+
private static String label(boolean up) {
127+
return up ? "Up" : "Down";
128+
}
129+
130+
/** Wire-shape DTO for the health endpoint. Component order defines JSON field order. */
131+
private record HealthBody(String outcome, List<DependencyBody> dependencies) {}
132+
133+
/** Wire-shape DTO for a single dependency entry. */
134+
private record DependencyBody(String id, String status) {}
135+
70136
/**
71137
* Serves a classpath resource. Content-Type is inferred from the file extension. The resource is
72138
* loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.retailsvc.http;
2+
3+
import java.util.List;
4+
import java.util.Objects;
5+
6+
/**
7+
* Carrier for the {@link Handlers#healthHandler health handler} response.
8+
*
9+
* <p>The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} on the
10+
* way out; callers work in booleans. Construct the outcome from whatever check-running mechanism
11+
* the caller prefers — this library has no opinion.
12+
*
13+
* @param up overall health — {@code true} renders as {@code "Up"} with HTTP 200; {@code false}
14+
* renders as {@code "Down"} with HTTP 503
15+
* @param dependencies per-dependency statuses; {@code null} is normalised to an empty list
16+
*/
17+
public record HealthOutcome(boolean up, List<Dependency> dependencies) {
18+
19+
public HealthOutcome {
20+
dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of()));
21+
}
22+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.retailsvc.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
5+
6+
import org.junit.jupiter.api.Test;
7+
8+
class DependencyTest {
9+
10+
@Test
11+
void holdsIdAndUp() {
12+
Dependency d = new Dependency("jdbc", true);
13+
assertThat(d.id()).isEqualTo("jdbc");
14+
assertThat(d.up()).isTrue();
15+
}
16+
17+
@Test
18+
void rejectsNullId() {
19+
assertThatNullPointerException()
20+
.isThrownBy(() -> new Dependency(null, true))
21+
.withMessageContaining("id");
22+
}
23+
}

0 commit comments

Comments
 (0)