Skip to content
Merged
2 changes: 1 addition & 1 deletion build.savant
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.fusionauth</groupId>
<artifactId>java-http</artifactId>
<version>1.1.2</version>
<version>1.2.0</version>
<packaging>jar</packaging>

<name>Java HTTP library (client and server)</name>
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/io/fusionauth/http/server/Configurable.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* This allows you to customize the status code and logging behavior.
* <p>
* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public class HTTPServerConfiguration implements Configurable<HTTPServerConfigura

private Duration shutdownDuration = Duration.ofSeconds(10);

private HTTPUnexpectedExceptionHandler unexpectedExceptionHandler = new DefaultHTTPUnexpectedExceptionHandler();

private Duration writeThroughputCalculationDelayDuration = Duration.ofSeconds(5);

/**
Expand Down Expand Up @@ -275,6 +277,13 @@ public Duration getShutdownDuration() {
return shutdownDuration;
}

/**
* @return The HTTP unexpected exception handler for this server. Never null.
*/
public HTTPUnexpectedExceptionHandler getUnexpectedExceptionHandler() {
return unexpectedExceptionHandler;
}

/**
* @return the duration that will be used to delay the calculation and enforcement of the minimum write throughput.
*/
Expand Down Expand Up @@ -334,7 +343,7 @@ public HTTPServerConfiguration withContextPath(String contextPath) {
*/
@Override
public HTTPServerConfiguration withExpectValidator(ExpectValidator validator) {
Objects.requireNonNull(handler, "You cannot set ExpectValidator to null");
Objects.requireNonNull(validator, "You cannot set the expect validator to null");
this.expectValidator = validator;
return this;
}
Expand All @@ -344,7 +353,7 @@ public HTTPServerConfiguration withExpectValidator(ExpectValidator validator) {
*/
@Override
public HTTPServerConfiguration withHandler(HTTPHandler handler) {
Objects.requireNonNull(handler, "You cannot set HTTPHandler to null");
Objects.requireNonNull(handler, "You cannot set the handler to null");
this.handler = handler;
return this;
}
Expand All @@ -354,7 +363,7 @@ public HTTPServerConfiguration withHandler(HTTPHandler handler) {
*/
@Override
public HTTPServerConfiguration withInitialReadTimeout(Duration duration) {
Objects.requireNonNull(duration, "You cannot set the client timeout to null");
Objects.requireNonNull(duration, "You cannot set the client read timeout duration to null");
if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("The client timeout duration must be greater than 0");
}
Expand All @@ -379,7 +388,6 @@ public HTTPServerConfiguration withInstrumenter(Instrumenter instrumenter) {
@Override
public HTTPServerConfiguration withKeepAliveTimeoutDuration(Duration duration) {
Objects.requireNonNull(duration, "You cannot set the keep-alive timeout duration to null");

if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("The keep-alive timeout duration must be grater than 0");
}
Expand All @@ -393,7 +401,7 @@ public HTTPServerConfiguration withKeepAliveTimeoutDuration(Duration duration) {
*/
@Override
public HTTPServerConfiguration withListener(HTTPListenerConfiguration listener) {
Objects.requireNonNull(listener, "You cannot set HTTPListenerConfiguration to null");
Objects.requireNonNull(listener, "You cannot add a null HTTPListenerConfiguration");
this.listeners.add(listener);
return this;
}
Expand All @@ -403,7 +411,7 @@ public HTTPServerConfiguration withListener(HTTPListenerConfiguration listener)
*/
@Override
public HTTPServerConfiguration withLoggerFactory(LoggerFactory loggerFactory) {
Objects.requireNonNull(loggerFactory, "You cannot set LoggerFactory to null");
Objects.requireNonNull(loggerFactory, "You cannot set the logger factory to null");
this.loggerFactory = loggerFactory;
return this;
}
Expand Down Expand Up @@ -497,7 +505,6 @@ public HTTPServerConfiguration withMultipartBufferSize(int multipartBufferSize)
@Override
public HTTPServerConfiguration withMultipartConfiguration(MultipartConfiguration multipartStreamConfiguration) {
Objects.requireNonNull(multipartStreamConfiguration, "You cannot set the multipart stream configuration to null");

this.multipartStreamConfiguration = multipartStreamConfiguration;
return this;
}
Expand All @@ -508,7 +515,6 @@ public HTTPServerConfiguration withMultipartConfiguration(MultipartConfiguration
@Override
public HTTPServerConfiguration withProcessingTimeoutDuration(Duration duration) {
Objects.requireNonNull(duration, "You cannot set the processing timeout duration to null");

if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("The processing timeout duration must be grater than 0");
}
Expand All @@ -523,7 +529,6 @@ public HTTPServerConfiguration withProcessingTimeoutDuration(Duration duration)
@Override
public HTTPServerConfiguration withReadThroughputCalculationDelayDuration(Duration duration) {
Objects.requireNonNull(duration, "You cannot set the read throughput delay duration to null");

if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("The read throughput delay duration must be grater than 0");
}
Expand Down Expand Up @@ -564,7 +569,6 @@ public HTTPServerConfiguration withResponseBufferSize(int responseBufferSize) {
@Override
public HTTPServerConfiguration withShutdownDuration(Duration duration) {
Objects.requireNonNull(duration, "You cannot set the shutdown duration to null");

if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("The shutdown duration must be grater than 0");
}
Expand All @@ -573,13 +577,22 @@ public HTTPServerConfiguration withShutdownDuration(Duration duration) {
return this;
}

/**
* {@inheritDoc}
*/
@Override
public HTTPServerConfiguration withUnexpectedExceptionHandler(HTTPUnexpectedExceptionHandler unexpectedExceptionHandler) {
Objects.requireNonNull(unexpectedExceptionHandler, "You cannot set the unexpected exception handler to null");
this.unexpectedExceptionHandler = unexpectedExceptionHandler;
return this;
}

/**
* {@inheritDoc}
*/
@Override
public HTTPServerConfiguration withWriteThroughputCalculationDelayDuration(Duration duration) {
Objects.requireNonNull(duration, "You cannot set the write throughput delay duration to null");

if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("The write throughput delay duration must be grater than 0");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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;

/**
* An interface defining the HTTP unexpected exception handler contract.
*
* @author Daniel DeGroff
*/
public interface HTTPUnexpectedExceptionHandler {
/**
*
* This handler will be called when an unexpected exception is taken while processing an HTTP request by the HTTP worker.
* <p>
* 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);
}
16 changes: 11 additions & 5 deletions src/main/java/io/fusionauth/http/server/internal/HTTPWorker.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -104,6 +105,7 @@ public long getStartInstant() {
@Override
public void run() {
HTTPInputStream httpInputStream;
HTTPRequest request = null;
HTTPResponse response = null;

try {
Expand All @@ -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()));
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion src/test/java/io/fusionauth/http/BaseTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down