Skip to content

Commit d8a34c5

Browse files
committed
docs: After-response hook design
1 parent 6588fd3 commit d8a34c5

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
# After-Response Hook
2+
3+
**Status:** Proposed
4+
**Date:** 2026-05-20
5+
**Author:** thced
6+
7+
## Goal
8+
9+
Let library consumers register code that runs **after the HTTP response has been
10+
written to the wire**, on the same virtual thread that handled the request, with
11+
the library's request-scoped value still bound. Hook exceptions are swallowed so
12+
they never affect the client (which has already received the response anyway).
13+
14+
Typical uses: telemetry flushes, audit log emission, post-commit notifications,
15+
trace-span close, latency metrics with the final status code.
16+
17+
## API
18+
19+
### Global hook (boot-time)
20+
21+
```java
22+
@FunctionalInterface
23+
public interface AfterResponseHook {
24+
void after(Request request, Response response);
25+
}
26+
```
27+
28+
Registered on the builder:
29+
30+
```java
31+
OpenApiServer.builder()
32+
.afterResponseHook((req, resp) -> log.info("{} {}", req.operationId(), resp.status()))
33+
.build();
34+
```
35+
36+
Multiple hooks may be registered; they run in registration order.
37+
38+
### Per-request hook
39+
40+
Handlers (or interceptors) queue `Runnable`s on the current request:
41+
42+
```java
43+
public final class Request {
44+
public void afterResponse(Runnable runnable) { /* appends to internal queue */ }
45+
}
46+
```
47+
48+
Multiple runnables may be queued; they run FIFO.
49+
50+
### Order
51+
52+
Global hooks fire first (registration order), then per-request runnables (FIFO).
53+
54+
### Exception policy
55+
56+
Every hook invocation is wrapped in `try { ... } catch (Throwable t) { LOG.debug(...) }`.
57+
A throwing hook does not affect other hooks, the response (already sent), or the
58+
exchange. Errors are not propagated.
59+
60+
## Execution model
61+
62+
After-hooks fire on the **request virtual thread**, inside the existing
63+
`ScopedValue.where(DispatchHandler.CURRENT, request)` binding established by
64+
`RequestPreparationFilter`. No re-binding, no thread hand-off.
65+
66+
Hooks fire after the bytes have been flushed to the client — i.e., after either
67+
`ResponseRenderer.render(...)` returns in `DispatchHandler`, or
68+
`ExceptionHandler.handle(...)` has written an error response.
69+
70+
## Structural change
71+
72+
To run hooks inside the existing scoped binding without re-binding, the
73+
exception-handling responsibility moves from `ExceptionFilter` into
74+
`RequestPreparationFilter`. `ExceptionFilter` is deleted.
75+
76+
Filter chain before:
77+
78+
```
79+
ExceptionFilter → RequestPreparationFilter → SecurityFilter → DispatchHandler
80+
```
81+
82+
Filter chain after:
83+
84+
```
85+
RequestPreparationFilter → SecurityFilter → DispatchHandler
86+
```
87+
88+
`RequestPreparationFilter` is the single owner of the exchange. Pseudo-code:
89+
90+
```java
91+
try {
92+
// routing + parameter/body validation (may throw NotFound/MethodNotAllowed/Validation)
93+
Request request = build(...);
94+
ScopedValue.where(DispatchHandler.CURRENT, request).run(() -> {
95+
try {
96+
chain.doFilter(exchange); // Security → Dispatch (renders or throws)
97+
} catch (Throwable t) {
98+
exceptionHandler.handle(exchange, t); // writes error response to exchange
99+
}
100+
// response is sent; scope still bound
101+
fireAfterHooks(request, exchange);
102+
});
103+
} catch (Throwable t) {
104+
// pre-Request failure (404/405/validation): no Request, so no after-hooks
105+
exceptionHandler.handle(exchange, t);
106+
}
107+
```
108+
109+
The "extras" routes registered via `Builder.extraRoute(...)` keep their own
110+
`ExceptionFilter` wrapper — these routes have no OpenAPI Request and no
111+
after-hook semantics. `ExceptionFilter` the class is retained and used only for
112+
extras contexts; it is no longer added to the OpenAPI context's filter chain.
113+
114+
## The `Response` object passed to hooks
115+
116+
On the success path, hooks receive the exact `Response` rendered by
117+
`DispatchHandler` (after `ResponseDecorator`s).
118+
119+
On the error path, `ExceptionHandler` writes directly to the exchange and never
120+
produces a `Response`. To keep the hook signature uniform, the framework
121+
synthesises one after the error has been rendered:
122+
123+
```java
124+
new Response(
125+
exchange.getResponseCode(), // 4xx/5xx from ExceptionHandler
126+
null, // body already streamed; unavailable
127+
exchange.getResponseHeaders().getFirst("Content-Type"),
128+
flatten(exchange.getResponseHeaders())) // first value per header name
129+
```
130+
131+
Hooks must therefore treat `Response.body()` as **always `null` on error paths**.
132+
Status and headers are accurate. This is documented on `AfterResponseHook`.
133+
134+
## Edge cases
135+
136+
**Streaming responses.** `Response.stream(...)` writes the body via a
137+
`StreamingBody` callback inside `ResponseRenderer`. The hook fires after the
138+
streaming callback returns, i.e., after the last byte has been written.
139+
140+
**Per-request queue when handler is missing.** `MissingOperationHandlerException`
141+
is thrown by `DispatchHandler` after `Request` is built. The handler queue is
142+
empty (handler never ran), so only global hooks fire. The error-path synthetic
143+
`Response` is used.
144+
145+
**Pre-Request failures (404/405/validation).** No `Request` was built. Neither
146+
global nor per-request hooks fire. Documented limitation.
147+
148+
**Hook throws.** Logged at DEBUG, swallowed. Next hook still runs.
149+
150+
**`afterResponse` called after hooks have started.** The runner snapshots the
151+
queue before invoking the first per-request runnable. Calls to
152+
`afterResponse(...)` from inside a running hook, or from a leaked `Request`
153+
reference held after the response has been sent, are silently ignored. The
154+
queue stays appendable (no clear/lock) — the snapshot semantics are what
155+
guarantee deterministic execution. Documented; not enforced at runtime.
156+
157+
## Implementation outline
158+
159+
### `com.retailsvc.http.AfterResponseHook` (new public)
160+
161+
```java
162+
package com.retailsvc.http;
163+
164+
@FunctionalInterface
165+
public interface AfterResponseHook {
166+
void after(Request request, Response response);
167+
}
168+
```
169+
170+
### `com.retailsvc.http.Request` (modified)
171+
172+
Add an internal `List<Runnable>` field plus:
173+
174+
```java
175+
public void afterResponse(Runnable runnable) {
176+
Objects.requireNonNull(runnable, "runnable must not be null");
177+
afterHooks.add(runnable);
178+
}
179+
```
180+
181+
A package-private getter (`List<Runnable> afterHooks()`) exposes the list to
182+
`RequestPreparationFilter`. The list is initialised to an empty mutable
183+
`ArrayList` in the constructor. The runner snapshots the list before iterating
184+
so runnables added during hook execution are ignored.
185+
186+
The current `Request` constructor has seven parameters. Adding an eighth would
187+
ripple through call sites; instead the field is initialised internally and
188+
exposed only through `afterResponse(...)` and the package-private getter.
189+
190+
### `com.retailsvc.http.internal.RequestPreparationFilter` (modified)
191+
192+
- Constructor takes `ExceptionHandler` and `List<AfterResponseHook>` in addition
193+
to its current dependencies.
194+
- `doFilter` restructured per the pseudo-code above.
195+
- New private helper `fireAfterHooks(Request, HttpExchange)` builds the
196+
synthetic `Response` if needed, then invokes each hook inside a `try/catch`.
197+
198+
### `com.retailsvc.http.internal.ExceptionFilter`
199+
200+
- Deleted from the OpenAPI route's filter chain (folded into
201+
`RequestPreparationFilter`).
202+
- For `extraRoute` contexts, `OpenApiServer` continues to install an
203+
`ExceptionFilter` (or an inline equivalent) so unhandled exceptions from
204+
extras still flow to the user's `ExceptionHandler`.
205+
206+
### `com.retailsvc.http.OpenApiServer` (modified)
207+
208+
- `HandlerConfig` gains `List<AfterResponseHook> afterHooks`.
209+
- The OpenAPI context registration no longer adds `ExceptionFilter` first; it
210+
starts with the updated `RequestPreparationFilter` which is given the
211+
`ExceptionHandler` and the after-hook list.
212+
- `Builder.afterResponseHook(AfterResponseHook)` appends to an `ArrayList`.
213+
214+
### `com.retailsvc.http.internal.ResponseRenderer` (no change expected)
215+
216+
`render` already runs synchronously on the request thread.
217+
218+
## Testing strategy
219+
220+
Unit tests (Surefire, `*Test.java`):
221+
222+
- `RequestTest`: `afterResponse(null)` throws NPE; multiple calls queue in order.
223+
- `OpenApiServerBuilderTest` (new or extend existing): `afterResponseHook(null)`
224+
throws NPE; multiple hooks queue in order.
225+
226+
Integration tests (Failsafe, `*IT.java`) using the existing test server harness:
227+
228+
- **Success path:** handler returns 200; global hook + per-request hook both fire
229+
in order; both see the rendered status and operationId.
230+
- **Per-request only:** no global hook registered; handler queues two runnables;
231+
both fire FIFO.
232+
- **Handler throws:** handler queues a runnable then throws; runnable still
233+
fires (per-request queue is drained regardless); global hook sees synthetic
234+
Response with the error status.
235+
- **Hook throws:** first hook throws, second hook still runs; response to client
236+
is unaffected.
237+
- **Pre-Request failure:** request hits an unknown path; 404 returned; no hooks
238+
fire (assert global counter unchanged).
239+
- **Scoped value visibility:** hook reads `DispatchHandler.CURRENT.get()` and
240+
gets the same `Request` instance the handler saw.
241+
- **Thread identity:** hook captures `Thread.currentThread()` and the handler
242+
captures the same; assert equality (same virtual thread).
243+
244+
## Out of scope
245+
246+
- Async / off-thread hooks: explicitly not supported. Users wanting async
247+
behavior can submit to their own executor from inside a hook.
248+
- Ordering across global hooks of different priorities. Insertion order only.
249+
- Removing or de-registering hooks after `build()`.
250+
- Hooks on `extraRoute` handlers.
251+
- Mutating the response from a hook (impossible — bytes have been sent).

0 commit comments

Comments
 (0)