Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2017-2025 original authors
*
* 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
*
* https://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.micronaut.http.client;

import io.micronaut.core.annotation.NonNull;

/**
* All HTTP client implementations provide access to their
* configuration through this interface.
*
* @since 4.10.x
*/
public interface ConfiguredHttpClient {
/**
* Returns the configuration for the HTTP client instance.
*
* @return the HTTP client configuration
*/
@NonNull
HttpClientConfiguration getConfiguration();
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
* @author Graeme Rocher
* @since 1.0
*/
public interface HttpClient extends Closeable, LifeCycle<HttpClient> {
public interface HttpClient extends ConfiguredHttpClient, Closeable, LifeCycle<HttpClient> {

/**
* The default error type.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ public abstract class HttpClientConfiguration {
*/
public static final boolean DEFAULT_EXCEPTION_ON_ERROR_STATUS = true;

/**
* The default value.
*/
public static final boolean DEFAULT_EXCEPTION_ON_404_STATUS = false;

/**
* The default value.
*/
Expand Down Expand Up @@ -176,6 +181,9 @@ public abstract class HttpClientConfiguration {
private boolean followRedirects = DEFAULT_FOLLOW_REDIRECTS;

private boolean exceptionOnErrorStatus = DEFAULT_EXCEPTION_ON_ERROR_STATUS;

private boolean exceptionOn404Status = DEFAULT_EXCEPTION_ON_404_STATUS;

private boolean decompressionEnabled = true;

private SslConfiguration sslConfiguration = new ClientSslConfiguration();
Expand Down Expand Up @@ -234,6 +242,7 @@ public HttpClientConfiguration(HttpClientConfiguration copy) {
this.connectTtl = copy.connectTtl;
this.defaultCharset = copy.defaultCharset;
this.exceptionOnErrorStatus = copy.exceptionOnErrorStatus;
this.exceptionOn404Status = copy.exceptionOn404Status;
this.eventLoopGroup = copy.eventLoopGroup;
this.followRedirects = copy.followRedirects;
this.logLevel = copy.logLevel;
Expand Down Expand Up @@ -365,6 +374,13 @@ public boolean isExceptionOnErrorStatus() {
return exceptionOnErrorStatus;
}

/**
* @return Whether throwing an exception upon HTTP error status 404 is preferred.
*/
public boolean isExceptionOn404Status() {
return exceptionOn404Status;
}

/**
* Sets whether throwing an exception upon HTTP error status (&gt;= 400) is preferred. Default value ({@link io.micronaut.http.client.HttpClientConfiguration#DEFAULT_EXCEPTION_ON_ERROR_STATUS})
*
Expand All @@ -374,6 +390,15 @@ public void setExceptionOnErrorStatus(boolean exceptionOnErrorStatus) {
this.exceptionOnErrorStatus = exceptionOnErrorStatus;
}

/**
* Sets whether throwing an exception upon HTTP error status 404 is preferred. Default value ({@link io.micronaut.http.client.HttpClientConfiguration#DEFAULT_EXCEPTION_ON_404_STATUS})
*
* @param exceptionOn404Status Whether
*/
public void setExceptionOn404Status(boolean exceptionOn404Status) {
this.exceptionOn404Status = exceptionOn404Status;
}

/**
* Whether response content decompression is enabled in the Netty HTTP client.
* When disabled, the client will not add the HttpContentDecompressor to the pipeline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.ClientAttributes;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.HttpClientConfiguration;
import io.micronaut.http.client.HttpClientRegistry;
import io.micronaut.http.client.ReactiveClientResultTransformer;
import io.micronaut.http.client.StreamingHttpClient;
Expand Down Expand Up @@ -230,17 +231,18 @@ private Object handleSynchronous(MethodInvocationContext<Object, Object> context
request.getHeaders().remove(HttpHeaders.ACCEPT);
}

HttpClientConfiguration config = httpClient.getConfiguration();
if (HttpResponse.class.isAssignableFrom(javaReturnType)) {
return handleBlockingCall(
clientName, javaReturnType, () ->
clientName, javaReturnType, config, () ->
blockingHttpClient.exchange(request,
returnType.asArgument().getFirstTypeVariable().orElse(Argument.OBJECT_ARGUMENT),
errorType
));
} else if (void.class == javaReturnType) {
return handleBlockingCall(clientName, javaReturnType, () -> blockingHttpClient.exchange(request, null, errorType));
return handleBlockingCall(clientName, javaReturnType, config, () -> blockingHttpClient.exchange(request, null, errorType));
} else {
return handleBlockingCall(clientName, javaReturnType,
return handleBlockingCall(clientName, javaReturnType, config,
() -> blockingHttpClient.retrieve(request, returnType.asArgument(), errorType));
}
}
Expand Down Expand Up @@ -288,17 +290,20 @@ protected void doOnError(Throwable t) {
LOG.debug("Client [{}] received HTTP error response: {}", declaringType.getName(), t.getMessage(), t);
}

if (t instanceof HttpClientResponseException e) {
if (e.code() == HttpStatus.NOT_FOUND.getCode()) {
if (reactiveValueType == Optional.class) {
future.complete(Optional.empty());
} else if (HttpResponse.class.isAssignableFrom(reactiveValueType)) {
future.complete(e.getResponse());
} else {
future.complete(null);
}
return;
if (t instanceof HttpClientResponseException e && e.code() == HttpStatus.NOT_FOUND.getCode()) {
HttpClientConfiguration config = httpClient.getConfiguration();
if (config.isExceptionOn404Status()) {
future.completeExceptionally(t);
}

if (reactiveValueType == Optional.class) {
future.complete(Optional.empty());
} else if (HttpResponse.class.isAssignableFrom(reactiveValueType)) {
future.complete(e.getResponse());
} else {
future.complete(null);
}
return;
}

future.completeExceptionally(t);
Expand Down Expand Up @@ -625,7 +630,7 @@ private Object getValue(Argument argument,
}
}

private Object handleBlockingCall(String clientName, Class returnType, Supplier<Object> supplier) {
private Object handleBlockingCall(String clientName, Class returnType, HttpClientConfiguration configuration, Supplier<Object> supplier) {
try {
if (void.class == returnType) {
supplier.get();
Expand All @@ -637,8 +642,11 @@ private Object handleBlockingCall(String clientName, Class returnType, Supplier<
if (LOG.isDebugEnabled()) {
LOG.debug("Client [{}] received HTTP error response: {}", clientName, t.getMessage(), t);
}

if (t instanceof HttpClientResponseException exception && exception.code() == HttpStatus.NOT_FOUND.getCode()) {
if (configuration.isExceptionOn404Status()) {
throw t;
}

if (returnType == Optional.class) {
return Optional.empty();
} else if (HttpResponse.class.isAssignableFrom(returnType)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,9 @@ public <I, O, E> Publisher<HttpResponse<O>> exchange(@NonNull HttpRequest<I> req
public boolean isRunning() {
return false;
}

@Override
public HttpClientConfiguration getConfiguration() {
return configuration;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -491,9 +491,7 @@ static boolean isAcceptEvents(io.micronaut.http.HttpRequest<?> request) {
return acceptHeader != null && acceptHeader.equalsIgnoreCase(MediaType.TEXT_EVENT_STREAM);
}

/**
* @return The configuration used by this client
*/
@Override
public HttpClientConfiguration getConfiguration() {
return configuration;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.micronaut.http.client

import io.micronaut.context.annotation.ConfigurationProperties
import io.micronaut.context.annotation.Property
import io.micronaut.context.annotation.Requires
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import reactor.core.publisher.Mono
import spock.lang.Specification

import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException

@MicronautTest
@Property(name = 'spec.name', value = 'DeclarativeClient404ExceptionSpec')
class DeclarativeClient404ExceptionSpec extends Specification {

@Inject
ThrowOn404Client client

@Controller('/test404')
@Requires(property = 'spec.name', value = 'DeclarativeClient404ExceptionSpec')
static class TestController {
@Get('/notfound')
HttpResponse<String> notFound() {
return HttpResponse.notFound().body("Not found")
}
}

@Requires(property = 'spec.name', value = 'DeclarativeClient404ExceptionSpec')
@ConfigurationProperties('test.get_config')
static class ThrowOn404Config extends DefaultHttpClientConfiguration {
ThrowOn404Config() {
setExceptionOn404Status(true)
}
}

@Client(value = "/test404", configuration = ThrowOn404Config.class)
@Requires(property = 'spec.name', value = 'DeclarativeClient404ExceptionSpec')
static interface ThrowOn404Client {
@Get('/notfound')
String getSynchronous()

@Get('/notfound')
CompletableFuture<String> getAsync()

@Get('/notfound')
Mono<String> getReactive()
}

void "test synchronous 404 throws exception when exception-on-404-status is true"() {
when:
client.getSynchronous()

then:
def ex = thrown(HttpClientResponseException)
ex.status == HttpStatus.NOT_FOUND
}

void "test async 404 throws exception when exception-on-404-status is true"() {
when:
client.getAsync().get()

then:
def ex = thrown(ExecutionException)
ex.getCause() instanceof HttpClientResponseException
ex.getCause().getMessage() == "Client '/test404': Not Found"
}

void "test reactive 404 throws exception when exception-on-404-status is true"() {
when:
client.getReactive().block()

then:
def ex = thrown(HttpClientResponseException)
ex.status == HttpStatus.NOT_FOUND
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class DefaultHttpClientConfigurationSpec extends Specification {
'read-idle-timeout' | 'readIdleTimeout' | '-1' | Optional.empty()
'connect-ttl' | 'connectTtl' | '1s' | Optional.of(Duration.ofSeconds(1))
'exception-on-error-status' | 'exceptionOnErrorStatus' | 'false' | false
'exception-on-error-status' | 'exceptionOn404Status' | 'false' | false
'shutdown-quiet-period' | 'shutdownQuietPeriod' | '1ms' | Optional.of(Duration.ofMillis(1))
'shutdown-quiet-period' | 'shutdownQuietPeriod' | '2s' | Optional.of(Duration.ofSeconds(2))
'shutdown-timeout' | 'shutdownTimeout' | '100ms' | Optional.of(Duration.ofMillis(100))
Expand Down
Loading