|
| 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