Skip to content

Commit 90ae25f

Browse files
authored
fix: Run response decorators inside interceptor ScopedValue scope (#98)
1 parent ce48fc9 commit 90ae25f

7 files changed

Lines changed: 609 additions & 5 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,9 @@ OpenApiServer.builder()
577577

578578
Decorators run inside the interceptor's `ScopedValue` binding (the decorator transforms the
579579
`Response` returned by `next.proceed()`, which is still on the call stack), so
580-
`CORRELATION_ID.get()` / `TENANT_ID.get()` see the bound values.
580+
`CORRELATION_ID.get()` / `TENANT_ID.get()` see the bound values. If a decorator throws, the
581+
exception propagates through any wrapping interceptors before reaching the `ExceptionFilter`, so
582+
interceptors that catch around `next.proceed()` will observe decorator failures.
581583

582584
A handler in this setup is just business logic:
583585

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
# Decorator ScopedValue Scope Fix — Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Make `ResponseDecorator` instances run inside the `ScopedValue` bindings established by `RequestInterceptor`s, matching the contract documented in `README.md` ("Combining the two").
6+
7+
**Architecture:** Move the decorator loop from `DispatchHandler.handle` into the base case of the recursive `invoke(...)` helper. Decorators then execute after `handler.handle(request)` but *before* control unwinds through the interceptor stack, so every interceptor's `ScopedValue.where(...).call(...)` frame is still live when a decorator runs. A side effect is that interceptors observe the *decorated* Response on the way back up — explicitly endorsed by the README.
8+
9+
**Tech Stack:** Java 25, JDK `com.sun.net.httpserver.HttpServer`, JUnit 5, AssertJ, Mockito, Maven.
10+
11+
**Spec:** `docs/superpowers/specs/2026-05-23-decorator-scoped-value-scope-design.md`
12+
13+
---
14+
15+
## File Structure
16+
17+
- Modify: `src/main/java/com/retailsvc/http/internal/DispatchHandler.java` — relocate decorator loop.
18+
- Modify: `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java` — three new unit tests pinning the new contract.
19+
20+
No README change. No public API change. No new files.
21+
22+
---
23+
24+
## Task 1: Failing test — decorator sees interceptor-bound ScopedValue
25+
26+
**Files:**
27+
- Modify: `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java`
28+
29+
- [ ] **Step 1: Add imports and a class-level test ScopedValue**
30+
31+
Open `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java`. Add the following imports near the existing ones (keep alphabetical order with the rest of the file):
32+
33+
```java
34+
import com.retailsvc.http.RequestInterceptor;
35+
import com.retailsvc.http.ResponseDecorator;
36+
import java.util.concurrent.atomic.AtomicReference;
37+
```
38+
39+
Add a static field inside the class body, immediately after the class declaration line:
40+
41+
```java
42+
private static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();
43+
```
44+
45+
- [ ] **Step 2: Add a helper that builds a DispatchHandler with interceptors and decorators**
46+
47+
Add this overload of `dispatcher` to the class (place it directly under the existing `dispatcher` method):
48+
49+
```java
50+
private static DispatchHandler dispatcher(
51+
Map<String, RequestHandler> handlers,
52+
List<RequestInterceptor> interceptors,
53+
List<ResponseDecorator> decorators) {
54+
return new DispatchHandler(handlers, interceptors, decorators, new ResponseRenderer(Map.of()));
55+
}
56+
```
57+
58+
- [ ] **Step 3: Write the failing test**
59+
60+
Append this test method to the class:
61+
62+
```java
63+
@Test
64+
void decoratorSeesInterceptorBoundScopedValue() throws Exception {
65+
RequestHandler ok = req -> Response.status(HTTP_OK);
66+
RequestInterceptor bindCid =
67+
(request, next) -> ScopedValue.where(CORRELATION_ID, "cid-123").call(next::proceed);
68+
ResponseDecorator stampCid =
69+
(req, resp) -> resp.withHeader("X-Correlation-Id", CORRELATION_ID.get());
70+
71+
HttpExchange ex = stubExchange();
72+
AtomicReference<Response> rendered = new AtomicReference<>();
73+
74+
withRequest(
75+
"get-x",
76+
() -> {
77+
dispatcher(Map.of("get-x", ok), List.of(bindCid), List.of(stampCid)).handle(ex);
78+
rendered.set((Response) ex.getAttribute(DispatchHandler.RESPONSE_ATTR));
79+
return null;
80+
});
81+
82+
assertThat(rendered.get().headers()).containsEntry("X-Correlation-Id", "cid-123");
83+
}
84+
```
85+
86+
- [ ] **Step 4: Wire up `exchange.getAttribute` on the stub**
87+
88+
The stub returned by `stubExchange()` is a Mockito mock and `getAttribute(...)` returns `null` by default. Switch the stub to capture the `setAttribute(...)` call. Replace the existing `stubExchange()` body with:
89+
90+
```java
91+
private static HttpExchange stubExchange() {
92+
HttpExchange exchange = mock(HttpExchange.class);
93+
when(exchange.getResponseHeaders()).thenReturn(new Headers());
94+
Map<String, Object> attrs = new HashMap<>();
95+
doAnswer(
96+
inv -> {
97+
attrs.put(inv.getArgument(0), inv.getArgument(1));
98+
return null;
99+
})
100+
.when(exchange)
101+
.setAttribute(anyString(), any());
102+
when(exchange.getAttribute(anyString())).thenAnswer(inv -> attrs.get(inv.getArgument(0)));
103+
return exchange;
104+
}
105+
```
106+
107+
Add the imports the new stub needs (keep alphabetical):
108+
109+
```java
110+
import static org.mockito.ArgumentMatchers.any;
111+
import static org.mockito.ArgumentMatchers.anyString;
112+
import static org.mockito.Mockito.doAnswer;
113+
import java.util.HashMap;
114+
```
115+
116+
- [ ] **Step 5: Run the new test and confirm it FAILS for the right reason**
117+
118+
Run:
119+
120+
```bash
121+
mvn test -Dtest=DispatchHandlerTest#decoratorSeesInterceptorBoundScopedValue
122+
```
123+
124+
Expected: test fails with `java.util.NoSuchElementException: ScopedValue not bound` thrown from `stampCid` — proves the bug.
125+
126+
If it fails for any other reason (compile error, NPE in the stub, etc.), stop and fix that before continuing — the test must fail *because the bug exists*, not because the test is broken.
127+
128+
- [ ] **Step 6: Commit the failing test**
129+
130+
```bash
131+
git add src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java
132+
git commit -m "test: Decorator should see interceptor-bound ScopedValue"
133+
```
134+
135+
(Pre-commit will run Google Java Format; let it.)
136+
137+
---
138+
139+
## Task 2: Fix — move decorator loop inside the interceptor scope
140+
141+
**Files:**
142+
- Modify: `src/main/java/com/retailsvc/http/internal/DispatchHandler.java`
143+
144+
- [ ] **Step 1: Move the decorator loop into `invoke`'s base case**
145+
146+
Open `src/main/java/com/retailsvc/http/internal/DispatchHandler.java`. Replace the current `handle` and `invoke` methods with:
147+
148+
```java
149+
@Override
150+
public void handle(HttpExchange exchange) throws IOException {
151+
Request request = CURRENT.get();
152+
RequestHandler handler = handlers.get(request.operationId());
153+
Response response = invoke(0, request, handler);
154+
exchange.setAttribute(RESPONSE_ATTR, response);
155+
renderer.render(exchange, response);
156+
}
157+
158+
private Response invoke(int idx, Request request, RequestHandler handler) {
159+
if (idx == interceptors.size()) {
160+
Response response = handler.handle(request);
161+
for (ResponseDecorator decorator : decorators) {
162+
response = decorator.decorate(request, response);
163+
}
164+
return response;
165+
}
166+
return interceptors.get(idx).around(request, () -> invoke(idx + 1, request, handler));
167+
}
168+
```
169+
170+
The change: the `for (ResponseDecorator ...)` loop moved from `handle` into the `idx == interceptors.size()` branch of `invoke`. Nothing else in the file changes.
171+
172+
- [ ] **Step 2: Run the previously-failing test and confirm it PASSES**
173+
174+
Run:
175+
176+
```bash
177+
mvn test -Dtest=DispatchHandlerTest#decoratorSeesInterceptorBoundScopedValue
178+
```
179+
180+
Expected: PASS.
181+
182+
- [ ] **Step 3: Run the whole `DispatchHandlerTest` class**
183+
184+
Run:
185+
186+
```bash
187+
mvn test -Dtest=DispatchHandlerTest
188+
```
189+
190+
Expected: all tests PASS — pre-existing `invokesRegisteredHandler` should still pass; the new test passes.
191+
192+
- [ ] **Step 4: Commit the fix**
193+
194+
```bash
195+
git add src/main/java/com/retailsvc/http/internal/DispatchHandler.java
196+
git commit -m "fix: Run response decorators inside interceptor ScopedValue scope"
197+
```
198+
199+
---
200+
201+
## Task 3: Pin interceptor-observes-decorated-response
202+
203+
**Files:**
204+
- Modify: `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java`
205+
206+
- [ ] **Step 1: Add the test**
207+
208+
Append to the class:
209+
210+
```java
211+
@Test
212+
void interceptorObservesDecoratedResponse() throws Exception {
213+
RequestHandler ok = req -> Response.status(HTTP_OK);
214+
AtomicReference<Response> seen = new AtomicReference<>();
215+
RequestInterceptor capture =
216+
(request, next) -> {
217+
Response r = next.proceed();
218+
seen.set(r);
219+
return r;
220+
};
221+
ResponseDecorator stamp = (req, resp) -> resp.withHeader("X-Stamped", "yes");
222+
223+
HttpExchange ex = stubExchange();
224+
withRequest(
225+
"get-x",
226+
() -> {
227+
dispatcher(Map.of("get-x", ok), List.of(capture), List.of(stamp)).handle(ex);
228+
return null;
229+
});
230+
231+
assertThat(seen.get()).isNotNull();
232+
assertThat(seen.get().headers()).containsEntry("X-Stamped", "yes");
233+
}
234+
```
235+
236+
- [ ] **Step 2: Run the test**
237+
238+
```bash
239+
mvn test -Dtest=DispatchHandlerTest#interceptorObservesDecoratedResponse
240+
```
241+
242+
Expected: PASS.
243+
244+
- [ ] **Step 3: Commit**
245+
246+
```bash
247+
git add src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java
248+
git commit -m "test: Interceptor observes decorated response"
249+
```
250+
251+
---
252+
253+
## Task 4: Pin decorator-throw-is-catchable-by-interceptor
254+
255+
Adds `import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;` alongside the existing `HTTP_OK` static import (memory: `feedback_http_status_constants` — no magic numbers).
256+
257+
**Files:**
258+
- Modify: `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java`
259+
260+
- [ ] **Step 1: Add the test**
261+
262+
Append to the class:
263+
264+
```java
265+
@Test
266+
void interceptorCanCatchDecoratorFailure() throws Exception {
267+
RequestHandler ok = req -> Response.status(HTTP_OK);
268+
AtomicBoolean caught = new AtomicBoolean(false);
269+
RequestInterceptor catcher =
270+
(request, next) -> {
271+
try {
272+
return next.proceed();
273+
} catch (RuntimeException e) {
274+
caught.set(true);
275+
return Response.status(HTTP_INTERNAL_ERROR);
276+
}
277+
};
278+
ResponseDecorator boom =
279+
(req, resp) -> {
280+
throw new IllegalStateException("boom");
281+
};
282+
283+
HttpExchange ex = stubExchange();
284+
withRequest(
285+
"get-x",
286+
() -> {
287+
dispatcher(Map.of("get-x", ok), List.of(catcher), List.of(boom)).handle(ex);
288+
return null;
289+
});
290+
291+
assertThat(caught.get()).isTrue();
292+
}
293+
```
294+
295+
- [ ] **Step 2: Run the test**
296+
297+
```bash
298+
mvn test -Dtest=DispatchHandlerTest#interceptorCanCatchDecoratorFailure
299+
```
300+
301+
Expected: PASS. (Before the Task 2 fix this would have failed because the decorator exception propagated past the already-popped interceptor frame straight to `ExceptionFilter`; after the fix the throw happens *inside* the interceptor's `try`.)
302+
303+
- [ ] **Step 3: Commit**
304+
305+
```bash
306+
git add src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java
307+
git commit -m "test: Interceptor can catch decorator failures"
308+
```
309+
310+
---
311+
312+
## Task 5: Full verification
313+
314+
**Files:** none (verification only).
315+
316+
- [ ] **Step 1: Run the full unit suite**
317+
318+
```bash
319+
mvn test
320+
```
321+
322+
Expected: BUILD SUCCESS, zero failures, zero errors. No flakiness expected — all new tests are synchronous and use the existing `withRequest` ScopedValue harness.
323+
324+
- [ ] **Step 2: Run the integration suite**
325+
326+
```bash
327+
mvn verify
328+
```
329+
330+
Expected: BUILD SUCCESS. This runs Failsafe IT tests including `DecoratorAndInterceptorIT`, which exercises the same code path end-to-end through `HttpServer`. Confirms no regression in the existing decorator + interceptor IT.
331+
332+
- [ ] **Step 3: Inspect the JaCoCo report for `DispatchHandler`**
333+
334+
Open `target/site/jacoco/com.retailsvc.http.internal/DispatchHandler.html`. Confirm both branches of `invoke` (`idx == interceptors.size()` and the recursive case) are covered and the new decorator loop inside the base case is exercised. If a branch is uncovered, add a test before continuing.
335+
336+
- [ ] **Step 4: SonarLint pre-push check**
337+
338+
Per project memory `feedback_sonar_pre_push`, analyze every touched file with SonarLint MCP before pushing. The worktree caveat from `feedback_sonarlint_blind_to_worktrees` applies — SonarLint may return `not_found` for files only in the worktree. If so, rely on the CI scan for this branch and continue; do not skip the local check on files that *are* visible.
339+
340+
Run SonarLint MCP against:
341+
- `src/main/java/com/retailsvc/http/internal/DispatchHandler.java`
342+
- `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java`
343+
344+
Fix any new issues in this branch before pushing.
345+
346+
- [ ] **Step 5: Push and open PR**
347+
348+
```bash
349+
git push -u origin fix/decorator-scoped-values
350+
gh pr create --title "fix: Run response decorators inside interceptor ScopedValue scope" --body "$(cat <<'EOF'
351+
## Summary
352+
- Move the response-decorator loop into the base case of `DispatchHandler.invoke(...)` so decorators execute inside every `RequestInterceptor`'s `ScopedValue` scope. Honors the contract documented in README "Combining the two".
353+
- Interceptors observe the decorated `Response` on the way back up the stack.
354+
- A decorator exception now propagates through the interceptor chain (interceptors can catch it) before reaching `ExceptionFilter`.
355+
356+
Spec: `docs/superpowers/specs/2026-05-23-decorator-scoped-value-scope-design.md`
357+
358+
## Test plan
359+
- [x] New unit test: decorator reads interceptor-bound `ScopedValue`
360+
- [x] New unit test: interceptor sees decorator-added header on the returned `Response`
361+
- [x] New unit test: interceptor `try`/`catch` around `next.proceed()` catches a throwing decorator
362+
- [x] `mvn verify` passes (including `DecoratorAndInterceptorIT`)
363+
EOF
364+
)"
365+
```
366+
367+
---
368+
369+
## Notes for the implementer
370+
371+
- **Do not touch `RequestPreparationFilter` or `AfterResponseHook`.** The spec explicitly scopes those out. Fixing after-hooks to see interceptor ScopedValues is a separate, larger change with a contract shift.
372+
- **Do not edit `README.md`.** The README is already correct; this PR brings the implementation into compliance.
373+
- **Decorator order is preserved.** They still iterate in registration order; that was true before and is still true after the move.
374+
- **Branch is `fix/decorator-scoped-values`** (already created via worktree).
375+
- **Commit subjects start with a capital letter after the type prefix** — see memory `feedback_skip_commitlint_in_worktrees`.

0 commit comments

Comments
 (0)