|
| 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. |
0 commit comments