Skip to content

Commit 780a529

Browse files
committed
docs: Treat null probe return as Down+503
1 parent 9f4a2c9 commit 780a529

2 files changed

Lines changed: 33 additions & 19 deletions

File tree

docs/superpowers/plans/2026-05-20-health-handler.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,20 @@ class HealthHandlerTest {
522522
assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
523523
}
524524

525+
@Test
526+
void nullReturnFromProbeMapsToDown503() throws IOException {
527+
HttpExchange ex = newExchange("GET");
528+
Headers headers = new Headers();
529+
when(ex.getResponseHeaders()).thenReturn(headers);
530+
ByteArrayOutputStream body = new ByteArrayOutputStream();
531+
when(ex.getResponseBody()).thenReturn(body);
532+
533+
Handlers.healthHandler(() -> null).handle(ex);
534+
535+
verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size()));
536+
assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
537+
}
538+
525539
private static HttpExchange newExchange(String method) {
526540
HttpExchange ex = mock(HttpExchange.class);
527541
when(ex.getRequestMethod()).thenReturn(method);
@@ -572,9 +586,13 @@ Append the new method to the body of the `Handlers` class, right after `aliveHan
572586
try (exchange) {
573587
HealthOutcome outcome;
574588
try {
575-
outcome = probe.get();
589+
HealthOutcome result = probe.get();
590+
if (result == null) {
591+
throw new IllegalStateException("Health probe returned null");
592+
}
593+
outcome = result;
576594
} catch (RuntimeException e) {
577-
LOG.warn("Health probe threw", e);
595+
LOG.warn("Health probe failed", e);
578596
outcome = new HealthOutcome("Down", List.of());
579597
}
580598
byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8);
@@ -596,7 +614,7 @@ import com.retailsvc.http.internal.HealthRenderer;
596614
- [ ] **Step 4: Run the new tests**
597615

598616
Run: `mvn test -Dtest=HealthHandlerTest`
599-
Expected: PASS (6 tests).
617+
Expected: PASS (7 tests).
600618

601619
- [ ] **Step 5: Run the full unit-test suite to catch regressions**
602620

@@ -662,14 +680,7 @@ The gh CLI cannot create PRs in this repo (see `MEMORY.md`); after the push, han
662680
| `HealthOutcome` defensively copies the dependency list | 2 |
663681
| `HealthOutcome` accepts `null` deps as empty list | 2 |
664682
| `HealthRenderer` package-private under `internal`, hand-rolled, escapes `\\ \" \n \r \t \b \f` and `\uXXXX` for `<0x20` | 3 |
665-
| `null` return from `Supplier` propagates as 500 (intentional) | Covered indirectly: NPE thrown by `HealthRenderer.toJson(null)` → falls outside the `RuntimeException` catch is FALSE — `NullPointerException` extends `RuntimeException` and **would** be caught. See note below. |
666-
667-
**Note on `null` return:** The spec says a `null` return "propagates a 500". But the handler's `try { outcome = probe.get(); } catch (RuntimeException e)` block catches `NullPointerException` too (since NPE extends RuntimeException), so a `null` return is silently mapped to `Down`+503, **not** a 500. Two ways forward:
668-
669-
1. Accept the actual behavior and update the spec wording — `null` return = treated identically to a throwing probe (`Down`+503). This is arguably nicer behavior anyway: health endpoints should never 500.
670-
2. Explicitly null-check the probe result before the catch and re-throw as a non-RuntimeException, or check after the try and let it propagate.
671-
672-
Recommended: option 1 (spec edit, no code change). Flag this to the user at execution time before writing the test for the null case. The plan above intentionally does NOT include a test for the `null`-return case; resolve the spec ambiguity first.
683+
| `null` return from `Supplier``Down`+503 (same as throwing probe) | 4 (explicit null check inside try; `nullReturnFromProbeMapsToDown503` test) |
673684

674685
**Type consistency:** `HealthOutcome`, `Dependency`, `HealthRenderer.toJson(HealthOutcome)`, `Handlers.healthHandler(Supplier<HealthOutcome>)` are consistent across all tasks. Field names (`outcome`, `dependencies`, `id`, `status`) match the spec's wire format.
675686

docs/superpowers/specs/2026-05-20-health-handler-design.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,18 @@ exactly where any third-party integration belongs.
137137

138138
## Error handling
139139

140-
- `Supplier` returns `null`: `Objects.requireNonNull` inside the handler
141-
produces an NPE; this falls outside the `RuntimeException` catch and
142-
propagates up through `ExceptionFilter` (yielding a 500). Probes are
143-
expected to return a value; treating a `null` return as a programming
144-
error is intentional.
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+
145145
- `Supplier` throws `RuntimeException`: caught; logged at `warn`; rendered
146146
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.)
147150
- IOException while writing the response: not caught here; `ExceptionFilter`
148-
handles it.
151+
handles it (this is a transport-level failure, not a probe failure).
149152

150153
## Testing
151154

@@ -161,8 +164,8 @@ Unit tests only (Surefire). New file `HealthHandlerTest` (or extension of
161164
- HEAD → status code only, no body bytes.
162165
- POST → 405 with `Allow: GET, HEAD` header.
163166
- Probe throws `RuntimeException` → 503, body `{"outcome":"Down","dependencies":[]}`.
164-
- Probe returns `null`propagates (assertion: 500 via the default
165-
exception handler).
167+
- Probe returns `null`503, body `{"outcome":"Down","dependencies":[]}`
168+
(same behaviour as a throwing probe).
166169

167170
New file `HealthRendererTest`:
168171

0 commit comments

Comments
 (0)