Skip to content

Commit b1781da

Browse files
authored
Error handling no longer resets headers on response, as this caused loss of important headers, such as CORS. (#10581)
The only component that fully resets response is now Jersey feature, as we move between frameworks.
1 parent 51a8df1 commit b1781da

File tree

8 files changed

+145
-10
lines changed

8 files changed

+145
-10
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright (c) 2025 Oracle and/or its affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.helidon.webserver.cors;
18+
19+
import io.helidon.common.testing.http.junit5.HttpHeaderMatcher;
20+
import io.helidon.http.Header;
21+
import io.helidon.http.HeaderNames;
22+
import io.helidon.http.HeaderValues;
23+
import io.helidon.http.Status;
24+
import io.helidon.webclient.api.WebClient;
25+
import io.helidon.webserver.http.ErrorHandler;
26+
import io.helidon.webserver.http.HttpRouting;
27+
import io.helidon.webserver.http.HttpRules;
28+
import io.helidon.webserver.http.HttpService;
29+
import io.helidon.webserver.http.ServerRequest;
30+
import io.helidon.webserver.http.ServerResponse;
31+
import io.helidon.webserver.testing.junit5.ServerTest;
32+
import io.helidon.webserver.testing.junit5.SetUpRoute;
33+
34+
import org.junit.jupiter.api.Test;
35+
36+
import static org.hamcrest.CoreMatchers.is;
37+
import static org.hamcrest.MatcherAssert.assertThat;
38+
39+
@ServerTest
40+
class HeadersWithErrorHandlerTest {
41+
private static final Header CORS_ALL_ORIGINS = HeaderValues.create(HeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
42+
private static final Header TEST_ORIGIN = HeaderValues.create(HeaderNames.ORIGIN, "http://test.origin");
43+
44+
private final WebClient webClient;
45+
46+
HeadersWithErrorHandlerTest(WebClient webClient) {
47+
this.webClient = webClient;
48+
}
49+
50+
@SetUpRoute
51+
static void routing(HttpRouting.Builder routing) {
52+
routing.register(CorsSupport.create(), new TestHttpService())
53+
.error(RuntimeException.class, new TestErrorHandler());
54+
}
55+
56+
@Test
57+
void checkCorsOnSuccess() {
58+
var response = webClient.get("/ok")
59+
.header(TEST_ORIGIN)
60+
.request(String.class);
61+
62+
assertThat(response.status(), is(Status.OK_200));
63+
assertThat(response.entity(), is("hello"));
64+
assertThat(response.headers(), HttpHeaderMatcher.hasHeader(CORS_ALL_ORIGINS));
65+
}
66+
67+
@Test
68+
void checkCorsOnError() {
69+
var response = webClient.get("/error")
70+
.header(TEST_ORIGIN)
71+
.request(String.class);
72+
73+
assertThat(response.status(), is(Status.BAD_REQUEST_400));
74+
assertThat(response.entity(), is("Failed, but error handled"));
75+
assertThat(response.headers(), HttpHeaderMatcher.hasHeader(CORS_ALL_ORIGINS));
76+
}
77+
78+
private static class TestHttpService implements HttpService {
79+
@Override
80+
public void routing(HttpRules rules) {
81+
rules.get("/ok", (req, res) -> res.send("hello"))
82+
.get("/error", (req, res) -> {
83+
throw new RuntimeException("Unit test exception");
84+
});
85+
}
86+
}
87+
88+
private static class TestErrorHandler implements ErrorHandler<RuntimeException> {
89+
@Override
90+
public void handle(ServerRequest req, ServerResponse res, RuntimeException throwable) {
91+
res.status(Status.BAD_REQUEST_400)
92+
.send("Failed, but error handled");
93+
}
94+
}
95+
}

webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,16 @@ public boolean reset() {
221221
return true;
222222
}
223223

224+
@Override
225+
public boolean resetStream() {
226+
if (isSent || outputStream != null && outputStream.bytesWritten > 0) {
227+
return false;
228+
}
229+
streamingEntity = false;
230+
outputStream = null;
231+
return true;
232+
}
233+
224234
@Override
225235
public void commit() {
226236
if (outputStream != null) {

webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/Http2ErrorHandlingWithOutputStreamTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -218,6 +218,8 @@ private static class CustomRoutingHandler implements ErrorHandler<CustomExceptio
218218
public void handle(ServerRequest req, ServerResponse res, CustomException throwable) {
219219
res.status(Status.I_AM_A_TEAPOT_418);
220220
res.header(ERROR_HEADER_NAME, "err");
221+
// this is now the responsibility of an error handler, as otherwise we may remove CORS headers etc.
222+
res.headers().remove(MAIN_HEADER_NAME);
221223
res.send("TeaPotIAm");
222224
}
223225
}

webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/ErrorHandlingWithOutputStreamTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2023 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -157,6 +157,8 @@ private static class CustomRoutingHandler implements ErrorHandler<CustomExceptio
157157
public void handle(ServerRequest req, ServerResponse res, CustomException throwable) {
158158
res.status(Status.I_AM_A_TEAPOT_418);
159159
res.header(ERROR_HEADER_NAME, "z");
160+
// this is now the responsibility of an error handler, as otherwise we may remove CORS headers etc.
161+
res.headers().remove(MAIN_HEADER_NAME);
160162
res.send("CustomErrorContent");
161163
}
162164
}

webserver/webserver/src/main/java/io/helidon/webserver/http/ErrorHandlers.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ private void handleRequestException(ConnectionContext ctx,
143143
ServerRequest request,
144144
RoutingResponse response,
145145
RequestException e) {
146-
if (!response.reset()) {
146+
// we are only interested in resetting the streams, headers are at the discretion of the error handler
147+
if (!response.resetStream()) {
147148
ctx.log(LOGGER, System.Logger.Level.WARNING,
148149
"Request failed: %s, cannot send error response, as response already sent",
149150
e, request.prologue());
@@ -210,7 +211,8 @@ private void handleError(ConnectionContext ctx,
210211
RoutingResponse response,
211212
Throwable e,
212213
ErrorHandler<Throwable> it) {
213-
if (!response.reset()) {
214+
// we are only interested in resetting the streams, headers are at the discretion of the error handler
215+
if (!response.resetStream()) {
214216
ctx.log(LOGGER, System.Logger.Level.WARNING, "Unable to reset response for error handler.");
215217
throw new CloseConnectionException(
216218
"Cannot send response of a simple handler, status and headers already written", e);

webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingResponse.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2023 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -60,7 +60,7 @@ public interface RoutingResponse extends ServerResponse {
6060
* Return true if the underlying response buffers and headers can be reset and a new response can be sent.
6161
*
6262
* @return {@code true} if reset was successful and a new response can be created instead of the existing one,
63-
* {@code false} if reset failed and status and headers (and maybe entity bytes) were already sent
63+
* {@code false} if reset failed and status and headers (and maybe entity bytes) were already sent
6464
*/
6565
boolean reset();
6666

@@ -70,4 +70,18 @@ public interface RoutingResponse extends ServerResponse {
7070
* After this method is called, response cannot be {@link #reset()}.
7171
*/
7272
void commit();
73+
74+
/**
75+
* Return true if the underlying response buffers can be reset and a new response can be sent.
76+
* <p>
77+
* As opposed to {@link #reset()}, this method is not expected to reset headers already configured on the response
78+
* <p>
79+
* This method calls {@link #reset()} by default.
80+
*
81+
* @return {@code true} if reset was successful and a new response can be created instead of the existing one,
82+
* {@code false} if reset failed and status and headers (and maybe entity bytes) were already sent
83+
*/
84+
default boolean resetStream() {
85+
return reset();
86+
}
7387
}

webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,16 @@ public boolean reset() {
282282
return true;
283283
}
284284

285+
@Override
286+
public boolean resetStream() {
287+
if (isSent || outputStream != null && outputStream.totalBytesWritten() > 0) {
288+
return false;
289+
}
290+
streamingEntity = false;
291+
outputStream = null;
292+
return true;
293+
}
294+
285295
@Override
286296
public void commit() {
287297
if (outputStream != null) {

webserver/webserver/src/test/java/io/helidon/webserver/http/ErrorHandlersTest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -128,7 +128,7 @@ public void testCloseConnectionExceptionContainsCause() {
128128
ConnectionContext ctx = mock(ConnectionContext.class);
129129
RoutingRequest req = mock(RoutingRequest.class);
130130
RoutingResponse res = mock(RoutingResponse.class);
131-
when(res.reset()).thenReturn(false);
131+
when(res.resetStream()).thenReturn(false);
132132
ErrorHandlers handlers = ErrorHandlers.create(Map.of(OtherException.class,
133133
(request, response, t) -> res.send(t.getMessage())));
134134
try {
@@ -145,7 +145,7 @@ private void testNoHandler(ErrorHandlers handlers, Exception e, String message)
145145
ConnectionContext ctx = mock(ConnectionContext.class);
146146
RoutingRequest req = mock(RoutingRequest.class);
147147
RoutingResponse res = mock(RoutingResponse.class);
148-
when(res.reset()).thenReturn(true);
148+
when(res.resetStream()).thenReturn(true);
149149

150150
when(req.prologue()).thenReturn(HttpPrologue.create("http/1.0",
151151
"http",
@@ -177,7 +177,7 @@ private void testHandler(ErrorHandlers handlers, Exception e, String message) {
177177
RoutingRequest req = mock(RoutingRequest.class);
178178
RoutingResponse res = mock(RoutingResponse.class);
179179
when(res.isSent()).thenReturn(true);
180-
when(res.reset()).thenReturn(true);
180+
when(res.resetStream()).thenReturn(true);
181181

182182
handlers.runWithErrorHandling(ctx, req, res, () -> {
183183
throw e;

0 commit comments

Comments
 (0)