-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Health handling #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
d29f625
84d27c3
2fdece1
9f4a2c9
780a529
52f5b56
5fe3b7d
3e09075
cbc58bd
a63321f
0fee6d0
9cf7584
db08608
7709a90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| # Health handler | ||
|
|
||
| **Date:** 2026-05-20 | ||
| **Status:** Design — ready for implementation plan | ||
|
|
||
| ## Problem | ||
|
|
||
| Services built on this library need a `/health` endpoint that reports | ||
| overall health plus per-dependency status. The expected wire format is: | ||
|
|
||
| ```json | ||
| { | ||
| "outcome": "Up", | ||
| "dependencies": [ | ||
| { "id": "jdbc", "status": "Up" } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| We want a ready-to-use `HttpHandler` in this repo that produces that exact | ||
| shape. The handler must not depend on any specific health-check provider — | ||
| callers supply the data through a `Supplier`, so they can plug in whatever | ||
| mechanism (off-the-shelf or in-house) computes their dependency statuses. | ||
|
|
||
| ## Goals | ||
|
|
||
| 1. Add `Handlers.healthHandler(Supplier<HealthOutcome>)` that: | ||
| - Accepts GET and HEAD only (405 otherwise, with `Allow: GET, HEAD`). | ||
| - Returns `200 OK` with `Content-Type: application/json` when `outcome` is `Up`. | ||
| - Returns `503 Service Unavailable` with the same body shape when `outcome` is `Down`. | ||
| - Never propagates a probe failure as a 500 — a throwing `Supplier` yields | ||
| `Down` + empty dependency list + 503. | ||
| 2. Define small public records `HealthOutcome` and `Dependency` in | ||
| `com.retailsvc.http` that own the wire shape, so this library has no | ||
| runtime or compile-time dependency on any specific health-check provider. | ||
| 3. Reuse existing infrastructure (`MethodLimitedHandler`, hand-rolled | ||
| JSON rendering á la `ProblemDetailRenderer`). No new third-party deps. | ||
|
|
||
| ## Non-goals | ||
|
|
||
| - Bundling or running health checks. Callers compute their own outcome | ||
| (typically by adapting whatever check-runner they use into a | ||
| `HealthOutcome`) and pass it in via the `Supplier`. | ||
| - Caching of probe results. If a caller's checks are expensive, they | ||
| memoize on their side of the `Supplier`. | ||
| - A configurable wire format — the field names `outcome`, `dependencies`, | ||
| `id`, `status` and the string values `Up` / `Down` are fixed. | ||
| - Configurable Content-Type or status codes — fixed at `application/json` + | ||
| 200/503. | ||
| - An integration test — unit coverage is sufficient; `MethodLimitedHandler` | ||
| itself is already integration-tested elsewhere. | ||
|
|
||
| ## Design | ||
|
|
||
| ### Public types — `com.retailsvc.http` | ||
|
|
||
| ```java | ||
| public record HealthOutcome(String outcome, List<Dependency> dependencies) { | ||
| public HealthOutcome { | ||
| Objects.requireNonNull(outcome, "outcome"); | ||
| dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); | ||
| } | ||
|
|
||
| public boolean isUp() { | ||
| return "Up".equalsIgnoreCase(outcome); | ||
| } | ||
| } | ||
|
|
||
| public record Dependency(String id, String status) { | ||
| public Dependency { | ||
| Objects.requireNonNull(id, "id"); | ||
| Objects.requireNonNull(status, "status"); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| `HealthOutcome.isUp()` is case-insensitive so callers that pass `"Up"`, | ||
| `"UP"`, or `"up"` all map to a healthy 200 response. | ||
|
|
||
| ### Public API — `Handlers.healthHandler` | ||
|
|
||
| ```java | ||
| public static HttpHandler healthHandler(Supplier<HealthOutcome> probe) { | ||
| Objects.requireNonNull(probe, "probe"); | ||
| return new MethodLimitedHandler(exchange -> { | ||
| try (exchange) { | ||
| HealthOutcome outcome; | ||
| try { | ||
| outcome = probe.get(); | ||
| } catch (RuntimeException e) { | ||
| LOG.warn("Health probe threw", e); | ||
| outcome = new HealthOutcome("Down", List.of()); | ||
| } | ||
| byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8); | ||
| int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE; | ||
| exchange.getResponseHeaders().add("Content-Type", "application/json"); | ||
| exchange.sendResponseHeaders(status, body.length); | ||
| exchange.getResponseBody().write(body); | ||
| } | ||
| }); | ||
| } | ||
| ``` | ||
|
|
||
| `HTTP_OK` and `HTTP_UNAVAILABLE` come from `java.net.HttpURLConnection` (per | ||
| project convention — no magic numbers). | ||
|
|
||
| ### Internal — `com.retailsvc.http.internal.HealthRenderer` | ||
|
|
||
| Package-private final class with a private constructor and a single | ||
| `static String toJson(HealthOutcome)` method. Implementation mirrors | ||
| `ProblemDetailRenderer`: hand-rolled `StringBuilder`, manual JSON-string | ||
| escaping for `\\`, `\"`, `\n`, `\r`, `\t`, `\b`, `\f`, and `\uXXXX` for any | ||
| remaining control characters below `0x20`. | ||
|
|
||
| ### Caller-side wiring (illustrative, not part of this repo) | ||
|
|
||
| ```java | ||
| server = OpenApiServer.builder() | ||
| .spec(spec) | ||
| .jsonMapper(mapper) | ||
| .handlers(operationHandlers) | ||
| .addHandler("/health", Handlers.healthHandler(() -> { | ||
| // Caller computes per-dependency statuses however they choose | ||
| // and adapts the result into HealthOutcome / Dependency. | ||
| List<Dependency> deps = List.of( | ||
| new Dependency("jdbc", checkDatabase() ? "Up" : "Down"), | ||
| new Dependency("cache", checkCache() ? "Up" : "Down")); | ||
| String outcome = deps.stream().allMatch(d -> "Up".equalsIgnoreCase(d.status())) | ||
| ? "Up" : "Down"; | ||
| return new HealthOutcome(outcome, deps); | ||
| })) | ||
| .build(); | ||
| ``` | ||
|
|
||
| The `Supplier` is the only place that knows how to compute health, which is | ||
| exactly where any third-party integration belongs. | ||
|
|
||
| ## Error handling | ||
|
|
||
| Health endpoints should never 500 — load balancers and orchestrators interpret | ||
| 5xx-from-health-probe as "treat instance as unhealthy" only some of the time; | ||
| a 503 with a `Down` body is the unambiguous signal. The handler therefore | ||
| funnels every probe failure into the same `Down`+503 path: | ||
|
|
||
| - `Supplier` throws `RuntimeException`: caught; logged at `warn`; rendered | ||
| as `Down` with empty dependency list and 503. | ||
| - `Supplier` returns `null`: treated identically to a throwing probe — | ||
| logged at `warn` and rendered as `Down`+503. (The handler asserts | ||
| non-null via an explicit check inside the same `try` block.) | ||
| - IOException while writing the response: not caught here; `ExceptionFilter` | ||
| handles it (this is a transport-level failure, not a probe failure). | ||
|
|
||
| ## Testing | ||
|
|
||
| Unit tests only (Surefire). New file `HealthHandlerTest` (or extension of | ||
| `HandlersTest`): | ||
|
|
||
| - GET, `Up` outcome with dependencies → 200, `application/json`, body | ||
| equals expected JSON (parsed back via Jackson in test scope, asserted | ||
| field by field). | ||
| - GET, `Up` outcome with empty dependency list → 200, body has empty | ||
| array. | ||
| - GET, `Down` outcome → 503, body still rendered. | ||
| - HEAD → status code only, no body bytes. | ||
| - POST → 405 with `Allow: GET, HEAD` header. | ||
| - Probe throws `RuntimeException` → 503, body `{"outcome":"Down","dependencies":[]}`. | ||
| - Probe returns `null` → 503, body `{"outcome":"Down","dependencies":[]}` | ||
| (same behaviour as a throwing probe). | ||
|
|
||
| New file `HealthRendererTest`: | ||
|
|
||
| - Round-trip outcomes through Jackson to confirm valid JSON. | ||
| - Strings containing `"`, `\`, newline, tab, control char `` are | ||
| escaped correctly. | ||
|
|
||
| Records `HealthOutcome` and `Dependency` get tiny tests for null/empty | ||
| argument validation and (`HealthOutcome` only) `isUp()` case-insensitivity. | ||
|
|
||
| ## Out of scope / future | ||
|
|
||
| - Wiring the handler into `ServerLauncher` (the example launcher) — not | ||
| needed; the launcher exists for local development of the OpenAPI flow. | ||
| - A second `healthHandler` overload that takes a `Callable` or | ||
| `CompletionStage` — no concrete need yet. | ||
| - An integration test that exercises the handler through `OpenApiServer` | ||
| end-to-end. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.retailsvc.http; | ||
|
|
||
| import java.util.Objects; | ||
|
|
||
| /** | ||
| * A single dependency entry within a {@link HealthOutcome}. | ||
| * | ||
| * @param id stable identifier of the dependency (e.g. {@code "jdbc"}) | ||
| * @param status free-form status; {@code "Up"} (case-insensitive) is treated as healthy by {@link | ||
| * HealthOutcome#isUp()}; any other value is treated as unhealthy | ||
| */ | ||
| public record Dependency(String id, String status) { | ||
| public Dependency { | ||
| Objects.requireNonNull(id, "id"); | ||
| Objects.requireNonNull(status, "status"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package com.retailsvc.http; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Objects; | ||
|
|
||
| /** | ||
| * Wire-shape carrier for the {@link Handlers#healthHandler health handler} response. | ||
| * | ||
| * <p>The record owns the JSON shape on the wire — {@code {"outcome": "...", "dependencies": [ | ||
| * {"id": "...", "status": "..."} ]}}. Construct it from whatever check-running mechanism the caller | ||
| * prefers; this library has no opinion. | ||
| * | ||
| * @param outcome overall outcome; {@code "Up"} (case-insensitive) means healthy | ||
| * @param dependencies per-dependency statuses; {@code null} is normalised to an empty list | ||
| */ | ||
| public record HealthOutcome(String outcome, List<Dependency> dependencies) { | ||
|
|
||
| public HealthOutcome { | ||
| Objects.requireNonNull(outcome, "outcome"); | ||
| dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); | ||
| } | ||
|
|
||
| /** Returns {@code true} when {@link #outcome()} equals {@code "Up"} ignoring case. */ | ||
| public boolean isUp() { | ||
| return "Up".equalsIgnoreCase(outcome); | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we "hand roll" a serializer/renderer for health checks instead of relying on the Gson mapper we anyway will fallback to? This renderer means you cannot serve health as anything but application/json. I don't have any issues with this being quite similar to regular request handlers where it's quite predictable how they'd encode the data. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| package com.retailsvc.http.internal; | ||
|
|
||
| import com.retailsvc.http.Dependency; | ||
| import com.retailsvc.http.HealthOutcome; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * Hand-rolled JSON renderer for {@link HealthOutcome} responses. | ||
| * | ||
| * <p>Mirrors {@link ProblemDetailRenderer} — the library avoids pulling in a JSON writer for a | ||
| * handful of fixed fields with known shapes. | ||
| */ | ||
| public final class HealthRenderer { | ||
|
|
||
| /** Initial capacity sized for a typical health document with a handful of dependencies. */ | ||
| private static final int INITIAL_BUFFER_CAPACITY = 128; | ||
|
|
||
| /** Codepoints below this value are control characters and must be unicode-escaped in JSON. */ | ||
| private static final int FIRST_PRINTABLE_ASCII = 0x20; | ||
|
|
||
| private HealthRenderer() {} | ||
|
|
||
| public static String toJson(HealthOutcome outcome) { | ||
| StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); | ||
| out.append('{'); | ||
| appendStringField(out, "outcome", outcome.outcome()); | ||
| out.append(",\"dependencies\":["); | ||
| appendDependencies(out, outcome.dependencies()); | ||
| out.append("]}"); | ||
| return out.toString(); | ||
| } | ||
|
|
||
| private static void appendDependencies(StringBuilder out, List<Dependency> deps) { | ||
| for (int i = 0; i < deps.size(); i++) { | ||
| if (i > 0) { | ||
| out.append(','); | ||
| } | ||
| Dependency d = deps.get(i); | ||
| out.append('{'); | ||
| appendStringField(out, "id", d.id()); | ||
| out.append(','); | ||
| appendStringField(out, "status", d.status()); | ||
| out.append('}'); | ||
| } | ||
| } | ||
|
|
||
| private static void appendStringField(StringBuilder out, String name, String value) { | ||
| out.append('"').append(name).append("\":\""); | ||
| appendEscaped(out, value); | ||
| out.append('"'); | ||
| } | ||
|
|
||
| private static void appendEscaped(StringBuilder out, String value) { | ||
| for (int i = 0; i < value.length(); i++) { | ||
| char c = value.charAt(i); | ||
| switch (c) { | ||
| case '\\' -> out.append("\\\\"); | ||
| case '"' -> out.append("\\\""); | ||
| case '\n' -> out.append("\\n"); | ||
| case '\r' -> out.append("\\r"); | ||
| case '\t' -> out.append("\\t"); | ||
| case '\b' -> out.append("\\b"); | ||
| case '\f' -> out.append("\\f"); | ||
| default -> appendUnicodeOrLiteral(out, c); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static void appendUnicodeOrLiteral(StringBuilder out, char c) { | ||
| if (c < FIRST_PRINTABLE_ASCII) { | ||
| out.append(String.format("\\u%04x", (int) c)); | ||
| } else { | ||
| out.append(c); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.retailsvc.http; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatNullPointerException; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| class DependencyTest { | ||
|
|
||
| @Test | ||
| void holdsIdAndStatus() { | ||
| Dependency d = new Dependency("jdbc", "Up"); | ||
| assertThat(d.id()).isEqualTo("jdbc"); | ||
| assertThat(d.status()).isEqualTo("Up"); | ||
| } | ||
|
|
||
| @Test | ||
| void rejectsNullId() { | ||
| assertThatNullPointerException() | ||
| .isThrownBy(() -> new Dependency(null, "Up")) | ||
| .withMessageContaining("id"); | ||
| } | ||
|
|
||
| @Test | ||
| void rejectsNullStatus() { | ||
| assertThatNullPointerException() | ||
| .isThrownBy(() -> new Dependency("jdbc", null)) | ||
| .withMessageContaining("status"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not boolean outcome and let the "wireshape" translate it to outcome "Up"?