Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
687 changes: 687 additions & 0 deletions docs/superpowers/plans/2026-05-20-health-handler.md

Large diffs are not rendered by default.

186 changes: 186 additions & 0 deletions docs/superpowers/specs/2026-05-20-health-handler-design.md
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.
17 changes: 17 additions & 0 deletions src/main/java/com/retailsvc/http/Dependency.java
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");
}
}
36 changes: 36 additions & 0 deletions src/main/java/com/retailsvc/http/Handlers.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.retailsvc.http.internal.ClasspathResourceHandler;
import com.retailsvc.http.internal.HealthRenderer;
import com.retailsvc.http.internal.MethodLimitedHandler;
import com.retailsvc.http.internal.ProblemDetailRenderer;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -67,6 +73,36 @@ public static HttpHandler aliveHandler() {
});
}

/**
* Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body
* when the supplied probe reports {@code "Up"} (case-insensitive), and 503 with the same body
* shape otherwise. A probe that throws a {@link RuntimeException} or returns {@code null} is
* mapped to a {@code "Down"} outcome with an empty dependency list (and 503); the failure is
* never propagated to the default exception handler.
*
* @param probe supplier of the current {@link HealthOutcome}
*/
public static HttpHandler healthHandler(Supplier<HealthOutcome> probe) {
Objects.requireNonNull(probe, "probe");
return new MethodLimitedHandler(
exchange -> {
try (exchange) {
HealthOutcome outcome;
try {
outcome = Objects.requireNonNull(probe.get(), "Health probe returned null");
} catch (RuntimeException e) {
LOG.warn("Health probe failed", 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);
}
});
}

/**
* Serves a classpath resource. Content-Type is inferred from the file extension. The resource is
* loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}.
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/com/retailsvc/http/HealthOutcome.java
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) {

Copy link
Copy Markdown

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


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);
}
}
76 changes: 76 additions & 0 deletions src/main/java/com/retailsvc/http/internal/HealthRenderer.java

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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);
}
}
}
30 changes: 30 additions & 0 deletions src/test/java/com/retailsvc/http/DependencyTest.java
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");
}
}
Loading
Loading