Skip to content

Commit 0641a35

Browse files
committed
feat: Add cause overloads to BadRequestException and NotFoundException
Add (Throwable cause) overloads so user handlers can preserve the underlying exception when translating downstream failures to a 4xx/404. The default ExceptionHandler now DEBUG-logs the cause for these two types when present, so the cause stack trace is recoverable without changing the wire response.
1 parent 947758c commit 0641a35

6 files changed

Lines changed: 149 additions & 9 deletions

File tree

src/main/java/com/retailsvc/http/BadRequestException.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,28 @@ public final class BadRequestException extends RuntimeException {
2121
private final String keyword;
2222

2323
public BadRequestException(String detail) {
24-
this(DEFAULT_STATUS, detail, null, null);
24+
this(DEFAULT_STATUS, detail, null, null, null);
25+
}
26+
27+
public BadRequestException(String detail, Throwable cause) {
28+
this(DEFAULT_STATUS, detail, null, null, cause);
2529
}
2630

2731
public BadRequestException(int status, String detail) {
28-
this(status, detail, null, null);
32+
this(status, detail, null, null, null);
33+
}
34+
35+
public BadRequestException(int status, String detail, Throwable cause) {
36+
this(status, detail, null, null, cause);
2937
}
3038

3139
public BadRequestException(int status, String detail, String pointer, String keyword) {
32-
super(Objects.requireNonNull(detail, "detail must not be null"));
40+
this(status, detail, pointer, keyword, null);
41+
}
42+
43+
public BadRequestException(
44+
int status, String detail, String pointer, String keyword, Throwable cause) {
45+
super(Objects.requireNonNull(detail, "detail must not be null"), cause);
3346
if (status < 400 || status > 499) {
3447
throw new IllegalArgumentException("status must be 4xx, got " + status);
3548
}

src/main/java/com/retailsvc/http/Handlers.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,21 @@ public static ExceptionHandler defaultExceptionHandler() {
6464
HTTP_BAD_REQUEST,
6565
ProblemDetailRenderer.renderJson(ProblemDetail.forValidation(ve.error())),
6666
"application/problem+json");
67-
case BadRequestException bre ->
68-
Response.bytes(
69-
bre.status(),
70-
ProblemDetailRenderer.renderJson(ProblemDetail.forBadRequest(bre)),
71-
"application/problem+json");
72-
case NotFoundException _ -> Response.notFound();
67+
case BadRequestException bre -> {
68+
if (bre.getCause() != null && LOG.isDebugEnabled()) {
69+
LOG.debug("BadRequestException cause", bre.getCause());
70+
}
71+
yield Response.bytes(
72+
bre.status(),
73+
ProblemDetailRenderer.renderJson(ProblemDetail.forBadRequest(bre)),
74+
"application/problem+json");
75+
}
76+
case NotFoundException nfe -> {
77+
if (nfe.getCause() != null && LOG.isDebugEnabled()) {
78+
LOG.debug("NotFoundException cause", nfe.getCause());
79+
}
80+
yield Response.notFound();
81+
}
7382
case MethodNotAllowedException mna ->
7483
Response.status(HTTP_BAD_METHOD)
7584
.withHeader(

src/main/java/com/retailsvc/http/NotFoundException.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ public final class NotFoundException extends RuntimeException {
44
public NotFoundException(String message) {
55
super(message);
66
}
7+
8+
public NotFoundException(String message, Throwable cause) {
9+
super(message, cause);
10+
}
711
}

src/test/java/com/retailsvc/http/BadRequestExceptionTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ void rejectsNon4xxStatus() {
4343
.hasMessageContaining("4xx");
4444
}
4545

46+
@Test
47+
void preservesCause() {
48+
Throwable cause = new IllegalStateException("root");
49+
50+
BadRequestException defaultStatus = new BadRequestException("bad", cause);
51+
BadRequestException withStatus = new BadRequestException(422, "bad", cause);
52+
BadRequestException full = new BadRequestException(422, "bad", "/x", "unique", cause);
53+
54+
assertThat(defaultStatus).hasCause(cause);
55+
assertThat(defaultStatus.status()).isEqualTo(400);
56+
assertThat(withStatus).hasCause(cause);
57+
assertThat(withStatus.status()).isEqualTo(422);
58+
assertThat(full).hasCause(cause);
59+
assertThat(full.pointer()).contains("/x");
60+
assertThat(full.keyword()).contains("unique");
61+
}
62+
4663
@Test
4764
void rejectsNullDetail() {
4865
assertThatThrownBy(() -> new BadRequestException(null))

src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,44 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44

5+
import ch.qos.logback.classic.Level;
6+
import ch.qos.logback.classic.Logger;
7+
import ch.qos.logback.classic.spi.ILoggingEvent;
8+
import ch.qos.logback.core.read.ListAppender;
59
import com.retailsvc.http.spec.HttpMethod;
610
import com.retailsvc.http.validate.ValidationError;
711
import java.nio.charset.StandardCharsets;
812
import java.util.Map;
913
import java.util.Set;
14+
import org.junit.jupiter.api.AfterEach;
15+
import org.junit.jupiter.api.BeforeEach;
1016
import org.junit.jupiter.api.Test;
17+
import org.slf4j.LoggerFactory;
1118

1219
class HandlersDefaultExceptionTest {
1320

1421
private static final TypeMapper JSON = new GsonTypeMapper();
1522

23+
private Logger handlersLogger;
24+
private Level originalLevel;
25+
private ListAppender<ILoggingEvent> appender;
26+
27+
@BeforeEach
28+
void attachAppender() {
29+
handlersLogger = (Logger) LoggerFactory.getLogger(Handlers.class);
30+
originalLevel = handlersLogger.getLevel();
31+
handlersLogger.setLevel(Level.DEBUG);
32+
appender = new ListAppender<>();
33+
appender.start();
34+
handlersLogger.addAppender(appender);
35+
}
36+
37+
@AfterEach
38+
void detachAppender() {
39+
handlersLogger.detachAppender(appender);
40+
handlersLogger.setLevel(originalLevel);
41+
}
42+
1643
@Test
1744
void validationExceptionRendersProblemJson() {
1845
Response resp =
@@ -70,6 +97,50 @@ void methodNotAllowedReturns405WithAllowHeader() {
7097
assertThat(resp.headers().get("Allow")).contains("GET").contains("POST");
7198
}
7299

100+
@Test
101+
void badRequestCauseLoggedAtDebug() {
102+
Throwable cause = new IllegalStateException("root");
103+
104+
Handlers.defaultExceptionHandler().handle(new BadRequestException("bad", cause));
105+
106+
assertThat(appender.list)
107+
.anySatisfy(
108+
event -> {
109+
assertThat(event.getLevel()).isEqualTo(Level.DEBUG);
110+
assertThat(event.getThrowableProxy().getClassName())
111+
.isEqualTo(IllegalStateException.class.getName());
112+
});
113+
}
114+
115+
@Test
116+
void badRequestWithoutCauseDoesNotLog() {
117+
Handlers.defaultExceptionHandler().handle(new BadRequestException("bad"));
118+
119+
assertThat(appender.list).isEmpty();
120+
}
121+
122+
@Test
123+
void notFoundCauseLoggedAtDebug() {
124+
Throwable cause = new IllegalStateException("root");
125+
126+
Handlers.defaultExceptionHandler().handle(new NotFoundException("missing", cause));
127+
128+
assertThat(appender.list)
129+
.anySatisfy(
130+
event -> {
131+
assertThat(event.getLevel()).isEqualTo(Level.DEBUG);
132+
assertThat(event.getThrowableProxy().getClassName())
133+
.isEqualTo(IllegalStateException.class.getName());
134+
});
135+
}
136+
137+
@Test
138+
void notFoundWithoutCauseDoesNotLog() {
139+
Handlers.defaultExceptionHandler().handle(new NotFoundException("missing"));
140+
141+
assertThat(appender.list).isEmpty();
142+
}
143+
73144
@Test
74145
void unknownExceptionReturns500() {
75146
Response resp = Handlers.defaultExceptionHandler().handle(new RuntimeException("kaboom"));
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.retailsvc.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
class NotFoundExceptionTest {
8+
9+
@Test
10+
void carriesMessage() {
11+
NotFoundException e = new NotFoundException("missing");
12+
13+
assertThat(e.getMessage()).isEqualTo("missing");
14+
assertThat(e.getCause()).isNull();
15+
}
16+
17+
@Test
18+
void preservesCause() {
19+
Throwable cause = new IllegalStateException("root");
20+
21+
NotFoundException e = new NotFoundException("missing", cause);
22+
23+
assertThat(e.getMessage()).isEqualTo("missing");
24+
assertThat(e).hasCause(cause);
25+
}
26+
}

0 commit comments

Comments
 (0)