Search code examples
micronaut

HttpClient not handling body after redirect


I am using an HttpClient (part of micronaut 2.2.0) to perform a GET to a resource that redirects from a Controller that looks like this:

@Controller
public class ExampleController {

    @Client("https://www.vrt.be/") @Inject
    RxHttpClient client;

    @Get
    public String getIt() {
        return client.toBlocking().retrieve("/");
    }
}

I am running this app via SAM CLI with a template that has a lambda function and an API gateway. When issuing a curl http://127.0.0.1:3000/ it throws an exception. This exception also happens when deploying the template to AWS itself.

19:38:58.007 [default-nioEventLoopGroup-1-1] DEBUG i.m.h.client.netty.DefaultHttpClient - Sending HTTP Request: GET /
19:38:58.008 [default-nioEventLoopGroup-1-1] DEBUG i.m.h.client.netty.DefaultHttpClient - Chosen Server: www.vrt.be(-1)
19:38:58.023 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - host: www.vrt.be
19:38:58.023 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - connection: close
19:38:58.873 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - HTTP Client Response Received for Request: GET https://www.vrt.be/
19:38:58.873 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Status Code: 301 Moved Permanently
19:38:58.874 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Content-Type: text/html; charset=iso-8859-1
19:38:58.874 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Content-Length: 230
19:38:58.874 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Connection: close
19:38:58.874 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Date: Tue, 01 Dec 2020 20:19:06 GMT
19:38:58.874 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Cache-Control: max-age=604800
19:38:58.875 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Location: https://www.vrt.be/nl/
19:38:58.875 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Vary: Accept-Encoding
19:38:58.875 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - X-Cache: Hit from cloudfront
...
19:38:58.876 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - Response Body
19:38:58.876 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - ----
19:38:58.876 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>301 Moved Permanently</title>
</head><body>
<h1>Moved Permanently</h1>
<p>The document has moved <a href="https://www.vrt.be/nl/">here</a>.</p>
</body></html>

19:38:58.877 [default-nioEventLoopGroup-1-1] TRACE i.m.h.client.netty.DefaultHttpClient - ----
19:38:58.989 [main] ERROR i.m.f.a.p.AbstractLambdaContainerHandler - Error while handling request
io.micronaut.http.client.exceptions.HttpClientResponseException: Error decoding HTTP response body: No request present
    at io.micronaut.http.client.netty.DefaultHttpClient$12.channelRead0(DefaultHttpClient.java:2148)
    at io.micronaut.http.client.netty.DefaultHttpClient$12.channelRead0(DefaultHttpClient.java:2021)
    at io.netty.channel.SimpleChannelInboundHandler.channelRead(SimpleChannelInboundHandler.java:99)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)

I am not sure if there is something wrong with my use of the HttpClient or if this is an actual issue in the implementation of HttpClient.


Solution

  • TL;DR

    Create the file

    src/main/resources/META-INF/services/io.micronaut.http.HttpResponseFactory
    

    with the following content:

    io.micronaut.http.simple.SimpleHttpResponseFactory
    

    It will register an additional HttpResponseFactory that will be used by any internal HTTP request.

    Why the problem exists?

    By default, the Micronaut function application registers a single HttpResponseFactory service called MicronautAwsProxyResponseFactory. It is designed to handle AWS API responses and delegate all other responses to the alternative HttpResponseFactory stored in the ALTERNATE static field. This field gets initialized by the SoftServiceLoader that checks if there are any other instances registered in any META-INF/services/io.micronaut.http.HttpResponseFactory file.

    The problem with your exemplary application starts in the DefaultHttpClient, line 2072. Here, we can find the redirectExchange flowable object that represents a redirection. It gets published to the event loop, and it defines (using the first() method) that it should return the result of the redirected request or HttpStatus.notFound() if the redirection does not emit any value in return.

    Now, if you look at the bottom of the stack trace, you will find this:

    Caused by: io.micronaut.http.server.exceptions.InternalServerException: No request present
        at io.micronaut.function.aws.proxy.model.factory.MicronautAwsProxyResponseFactory.status(MicronautAwsProxyResponseFactory.java:79)
        at io.micronaut.http.HttpResponseFactory.status(HttpResponseFactory.java:74)
        at io.micronaut.http.HttpResponse.notFound(HttpResponse.java:105)
        at io.micronaut.http.client.netty.DefaultHttpClient$12.channelRead0(DefaultHttpClient.java:2059)
        ... 51 more
    

    It shows what exactly causes the exception - it is io.micronaut.http.HttpResponse.notFound(). If we look at the implementation of that method, we will find this:

    static <T> MutableHttpResponse<T> notFound() {
        return HttpResponseFactory.INSTANCE.status(HttpStatus.NOT_FOUND);
    }
    

    The HttpResponseFactory.INSTANCE variable returns the factory registered by the service loader, which in our case is MicronautAwsProxyResponseFactory.

    Now let's take a look at how does MicronautAwsProxyResponseFactory implement the status(status,reason) method:

    @Override
    public <T> MutableHttpResponse<T> status(HttpStatus status, String reason) {
        final HttpRequest<Object> req = ServerRequestContext.currentRequest().orElse(null);
        if (req instanceof MicronautAwsProxyRequest) {
            final MicronautAwsProxyResponse<T> response = (MicronautAwsProxyResponse<T>) ((MicronautAwsProxyRequest<Object>) req).getResponse();
            return response.status(status, reason);
        } else {
            if (ALTERNATE != null) {
                return ALTERNATE.status(status, reason);
            } else {
                throw new InternalServerException("No request present");
            }
        }
    }
    

    The current request is not an instance of the MicronautAwsProxyRequest, so the flow goes to the else branch. Here we get ALTERNATE that is not set, and that is why the exception is thrown.

    When you register io.micronaut.http.simple.SimpleHttpResponseFactory in the service loader, the above flow will not throw an exception, and the result of ALTERNATE.status(status,reason) will be returned.

    This corner case is not easy to detect, especially that other modules like http-server-netty register its own HttpResponseFactory, and everything works. For instance, if you would add http-server-netty dependency to your project, it would work without any issue because it registers io.micronaut.http.server.netty.NettyHttpResponseFactory which handles all statuses without throwing an exception.

    I guess that a module like micronaut-http-client could potentially register this io.micronaut.http.simple.SimpleHttpResponseFactory for the service loader out of the box, but that's the question to the Micronaut dev team. It could, however, introduce some side effects, so I would just register this factory for the service loader in the application.

    PS: If you want to debug your problem, you can run a unit test like the one below with the debugger.

    package demo;
    
    import com.amazonaws.serverless.exceptions.ContainerInitializationException;
    import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder;
    import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext;
    import com.amazonaws.serverless.proxy.model.AwsProxyResponse;
    import com.amazonaws.services.lambda.runtime.Context;
    import io.micronaut.context.ApplicationContext;
    import io.micronaut.function.aws.proxy.MicronautLambdaContainerHandler;
    import io.micronaut.http.HttpMethod;
    import org.junit.jupiter.api.Test;
    
    class ExampleControllerTest {
    
        static MicronautLambdaContainerHandler handler;
    
        static Context lambdaContext = new MockLambdaContext();
    
        static {
            try {
                handler = new MicronautLambdaContainerHandler(
                        ApplicationContext.build()
                );
            } catch (ContainerInitializationException e) {
                e.printStackTrace();
            }
        }
    
        @Test
        void test() {
            AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/", HttpMethod.GET.toString());
    
            AwsProxyResponse response = handler.proxy(builder.build(), lambdaContext);
    
            System.out.println(response.getBody());
        }
    }