Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
18 changes: 18 additions & 0 deletions src/main/java/com/retailsvc/http/Dependency.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.retailsvc.http;

import java.util.Objects;

/**
* A single dependency entry within a {@link HealthOutcome}.
*
* <p>The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} for the
* {@code status} field.
*
* @param id stable identifier of the dependency (e.g. {@code "jdbc"})
* @param up whether the dependency is healthy
*/
public record Dependency(String id, boolean up) {
public Dependency {
Objects.requireNonNull(id, "id");
}
}
66 changes: 66 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,18 @@
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.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 +72,67 @@ 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 == true}, 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.
*
* <p>The wire shape is
*
* <pre>{@code
* {"outcome":"Up","dependencies":[{"id":"jdbc","status":"Up"}]}
* }</pre>
*
* <p>Serialisation is delegated to the supplied {@code jsonMapper} — typically the same {@link
* TypeMapper} the caller registered for {@code application/json} on the server. The handler hands
* the mapper a record-shaped DTO with the components in the order shown above; any standard JSON
* library (Gson, Jackson, …) serialises it identically.
*
* @param jsonMapper used to encode the wire-shape DTO to bytes
* @param probe supplier of the current {@link HealthOutcome}
*/
public static HttpHandler healthHandler(TypeMapper jsonMapper, Supplier<HealthOutcome> probe) {
Objects.requireNonNull(jsonMapper, "jsonMapper");
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(false, List.of());
}
byte[] body = jsonMapper.writeTo(toWireShape(outcome));
int status = outcome.up() ? HTTP_OK : HTTP_UNAVAILABLE;
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, body.length);
exchange.getResponseBody().write(body);
}
});
}

private static HealthBody toWireShape(HealthOutcome outcome) {
return new HealthBody(
label(outcome.up()),
outcome.dependencies().stream()
.map(d -> new DependencyBody(d.id(), label(d.up())))
.toList());
}

private static String label(boolean up) {
return up ? "Up" : "Down";
}

/** Wire-shape DTO for the health endpoint. Component order defines JSON field order. */
private record HealthBody(String outcome, List<DependencyBody> dependencies) {}

/** Wire-shape DTO for a single dependency entry. */
private record DependencyBody(String id, String status) {}

/**
* 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
22 changes: 22 additions & 0 deletions src/main/java/com/retailsvc/http/HealthOutcome.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.retailsvc.http;

import java.util.List;
import java.util.Objects;

/**
* Carrier for the {@link Handlers#healthHandler health handler} response.
*
* <p>The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} on the
* way out; callers work in booleans. Construct the outcome from whatever check-running mechanism
* the caller prefers — this library has no opinion.
*
* @param up overall health — {@code true} renders as {@code "Up"} with HTTP 200; {@code false}
* renders as {@code "Down"} with HTTP 503
* @param dependencies per-dependency statuses; {@code null} is normalised to an empty list
*/
public record HealthOutcome(boolean up, List<Dependency> dependencies) {

public HealthOutcome {
dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of()));
}
}
23 changes: 23 additions & 0 deletions src/test/java/com/retailsvc/http/DependencyTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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 holdsIdAndUp() {
Dependency d = new Dependency("jdbc", true);
assertThat(d.id()).isEqualTo("jdbc");
assertThat(d.up()).isTrue();
}

@Test
void rejectsNullId() {
assertThatNullPointerException()
.isThrownBy(() -> new Dependency(null, true))
.withMessageContaining("id");
}
}
Loading
Loading