diff --git a/build.savant b/build.savant index c7351a7..dd70019 100644 --- a/build.savant +++ b/build.savant @@ -18,7 +18,7 @@ restifyVersion = "4.2.1" slf4jVersion = "2.0.17" testngVersion = "7.11.0" -project(group: "io.fusionauth", name: "java-http", version: "1.1.2", licenses: ["ApacheV2_0"]) { +project(group: "io.fusionauth", name: "java-http", version: "1.2.0", licenses: ["ApacheV2_0"]) { workflow { fetch { // Dependency resolution order: diff --git a/pom.xml b/pom.xml index 4a813d0..426fdfa 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 io.fusionauth java-http - 1.1.2 + 1.2.0 jar Java HTTP library (client and server) diff --git a/src/main/java/io/fusionauth/http/server/Configurable.java b/src/main/java/io/fusionauth/http/server/Configurable.java index ace87f6..b745705 100644 --- a/src/main/java/io/fusionauth/http/server/Configurable.java +++ b/src/main/java/io/fusionauth/http/server/Configurable.java @@ -331,6 +331,23 @@ default T withShutdownDuration(Duration duration) { return (T) this; } + /** + * + * Sets the unexpected exception handler. This handler will be called when an unexpected exception is taken while processing the HTTP + * request by the HTTP worker. + *

+ * This allows you to customize the status code and logging behavior. + *

+ * Must not be null. + * + * @param unexpectedExceptionHandler The unexpected exception handler. + * @return This. + */ + default T withUnexpectedExceptionHandler(HTTPUnexpectedExceptionHandler unexpectedExceptionHandler) { + configuration().withUnexpectedExceptionHandler(unexpectedExceptionHandler); + return (T) this; + } + /** * This configures the duration of the initial delay before calculating and enforcing the minimum write throughput. Defaults to 5 * seconds. diff --git a/src/main/java/io/fusionauth/http/server/DefaultHTTPUnexpectedExceptionHandler.java b/src/main/java/io/fusionauth/http/server/DefaultHTTPUnexpectedExceptionHandler.java new file mode 100644 index 0000000..e6db6e6 --- /dev/null +++ b/src/main/java/io/fusionauth/http/server/DefaultHTTPUnexpectedExceptionHandler.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the License. + */ +package io.fusionauth.http.server; + +/** + * THe default HTTP unexpected exception handler. + * + * @author Daniel DeGroff + */ +public class DefaultHTTPUnexpectedExceptionHandler implements HTTPUnexpectedExceptionHandler { + @Override + public void handle(ExceptionHandlerContext context) { + context.getLogger() + .error(String.format("[%s] Closing socket with status [%d]. An HTTP worker threw an exception while processing a request.", + Thread.currentThread().threadId(), + context.getStatusCode()), + context.getThrowable()); + } +} diff --git a/src/main/java/io/fusionauth/http/server/ExceptionHandlerContext.java b/src/main/java/io/fusionauth/http/server/ExceptionHandlerContext.java new file mode 100644 index 0000000..894cf42 --- /dev/null +++ b/src/main/java/io/fusionauth/http/server/ExceptionHandlerContext.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025, FusionAuth, All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the License. + */ +package io.fusionauth.http.server; + +import io.fusionauth.http.log.Logger; + +/** + * Provide context to the exception handler. + * + * @author Daniel DeGroff + */ +public class ExceptionHandlerContext { + private final Logger logger; + + private final HTTPRequest request; + + private final Throwable throwable; + + private int statusCode; + + public ExceptionHandlerContext(Logger logger, HTTPRequest request, int statusCode, Throwable throwable) { + this.logger = logger; + this.request = request; + this.statusCode = statusCode; + this.throwable = throwable; + } + + /** + * This is provided for convenience, but you may wish to use your own logger. + * + * @return the optional logger to use in the exception handler. + */ + public Logger getLogger() { + return logger; + } + + /** + * This may be useful if you wish to know additional context of the exception such as the URI of the current HTTP request. + *

+ * Modifications to this object will have no effect on current or futures requests. + * + * @return the current HTTP request, or null if this exception was taking prior to constructing the HTTP request. This is unlikely but + * please account for this value being null. + */ + public HTTPRequest getRequest() { + return request; + } + + /** + * @return the desired status code for the HTTP response. + */ + public int getStatusCode() { + return statusCode; + } + + /** + * Suggest a status code for the HTTP response. This value will be used unless the response has already been committed meaning bytes have + * already been written to the client and the HTTP server is not able to modify the response code. + * + * @param statusCode the desired status code to set on the HTTP response. + */ + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + /** + * @return the unexpected exception to handle + */ + public Throwable getThrowable() { + return throwable; + } +} diff --git a/src/main/java/io/fusionauth/http/server/HTTPServerConfiguration.java b/src/main/java/io/fusionauth/http/server/HTTPServerConfiguration.java index 113a8b8..2a1e6c2 100644 --- a/src/main/java/io/fusionauth/http/server/HTTPServerConfiguration.java +++ b/src/main/java/io/fusionauth/http/server/HTTPServerConfiguration.java @@ -79,6 +79,8 @@ public class HTTPServerConfiguration implements Configurable + * The intent is that this provides additional flexibility on the status code and the logging behavior when an unexpected exception + * caught. + * + * @param context the exception context + */ + void handle(ExceptionHandlerContext context); +} diff --git a/src/main/java/io/fusionauth/http/server/internal/HTTPWorker.java b/src/main/java/io/fusionauth/http/server/internal/HTTPWorker.java index 3dd8d87..1b150e8 100644 --- a/src/main/java/io/fusionauth/http/server/internal/HTTPWorker.java +++ b/src/main/java/io/fusionauth/http/server/internal/HTTPWorker.java @@ -31,6 +31,7 @@ import io.fusionauth.http.io.MultipartConfiguration; import io.fusionauth.http.io.PushbackInputStream; import io.fusionauth.http.log.Logger; +import io.fusionauth.http.server.ExceptionHandlerContext; import io.fusionauth.http.server.HTTPHandler; import io.fusionauth.http.server.HTTPListenerConfiguration; import io.fusionauth.http.server.HTTPRequest; @@ -104,6 +105,7 @@ public long getStartInstant() { @Override public void run() { HTTPInputStream httpInputStream; + HTTPRequest request = null; HTTPResponse response = null; try { @@ -113,7 +115,7 @@ public void run() { while (true) { logger.trace("[{}] Running HTTP worker. Block while we wait to read the preamble", Thread.currentThread().threadId()); - var request = new HTTPRequest(configuration.getContextPath(), listener.getCertificate() != null ? "https" : "http", listener.getPort(), socket.getInetAddress().getHostAddress()); + request = new HTTPRequest(configuration.getContextPath(), listener.getCertificate() != null ? "https" : "http", listener.getPort(), socket.getInetAddress().getHostAddress()); // Create a deep copy of the MultipartConfiguration so that the request may optionally modify the configuration on a per-request basis. request.getMultiPartStreamProcessor().setMultipartConfiguration(new MultipartConfiguration(configuration.getMultipartConfiguration())); @@ -268,10 +270,14 @@ public void run() { logger.debug(String.format("[%s] Closing socket with status [%d]. An IO exception was thrown during processing. These are pretty common.", Thread.currentThread().threadId(), Status.InternalServerError), e); closeSocketOnError(response, Status.InternalServerError); } catch (Throwable e) { - // Log the error and signal a failure - var status = Status.InternalServerError; - logger.error(String.format("[%s] Closing socket with status [%d]. An HTTP worker threw an exception while processing a request.", Thread.currentThread().threadId(), status), e); - closeSocketOnError(response, status); + ExceptionHandlerContext context = new ExceptionHandlerContext(logger, request, Status.InternalServerError, e); + try { + configuration.getUnexpectedExceptionHandler().handle(context); + } catch (Throwable ignore) { + } + + // Signal an error + closeSocketOnError(response, context.getStatusCode()); } finally { if (instrumenter != null) { instrumenter.workerStopped(); diff --git a/src/test/java/io/fusionauth/http/BaseTest.java b/src/test/java/io/fusionauth/http/BaseTest.java index 26fe0e3..447bbed 100644 --- a/src/test/java/io/fusionauth/http/BaseTest.java +++ b/src/test/java/io/fusionauth/http/BaseTest.java @@ -58,6 +58,7 @@ import io.fusionauth.http.log.Level; import io.fusionauth.http.log.LoggerFactory; import io.fusionauth.http.security.SecurityTools; +import io.fusionauth.http.server.AlwaysContinueExpectValidator; import io.fusionauth.http.server.ExpectValidator; import io.fusionauth.http.server.HTTPHandler; import io.fusionauth.http.server.HTTPListenerConfiguration; @@ -297,7 +298,7 @@ public HTTPServer makeServer(String scheme, HTTPHandler handler, Instrumenter in .withKeepAliveTimeoutDuration(ServerTimeout) .withInitialReadTimeout(ServerTimeout) .withProcessingTimeoutDuration(ServerTimeout) - .withExpectValidator(expectValidator) + .withExpectValidator(expectValidator != null ? expectValidator : new AlwaysContinueExpectValidator()) .withInstrumenter(instrumenter) .withLoggerFactory(factory) .withMinimumReadThroughput(200 * 1024)