Bug Report

Environment

spring-web 6.1.21

Description

EOFException when attempting to extract data from response while invoking RestTemplate.exchange. API is sending back a response expectedly with a 202 ACCEPTED, no body (not an empty body), and no Content-Length header. Seems like Spring doesn't account for this situation.

Under HttpMessageConverterExtractor.extractData(), it checks if there is a message body, and if so, whether the body is empty. Before this check, the headers are received and processed:

HttpHeaders.java:

/**
 * Return the length of the body in bytes, as specified by the
 * {@code Content-Length} header.
 * <p>Returns -1 when the content-length is unknown.
 */
public long getContentLength() {
    String value = getFirst(CONTENT_LENGTH);
    return (value != null ? Long.parseLong(value) : -1);
}

The Content-Length header is set to -1 since no Content-Length header was received.

The problem occurs under IntrospectingClientHttpResponse.hasMessageBody():

/**
 * Indicates whether the response has a message body.
 * <p>Implementation returns {@code false} for:
 * <ul>
 * <li>a response status of {@code 1XX}, {@code 204} or {@code 304}</li>
 * <li>a {@code Content-Length} header of {@code 0}</li>
 * </ul>
 * @return {@code true} if the response has a message body, {@code false} otherwise
 * @throws IOException in case of I/O errors
 */
public boolean hasMessageBody() throws IOException {
    HttpStatusCode statusCode = getStatusCode();
    if (statusCode.is1xxInformational() || statusCode == HttpStatus.NO_CONTENT ||
        statusCode == HttpStatus.NOT_MODIFIED) {
        return false;
    }
    if (getHeaders().getContentLength() == 0) {
        return false;
    }
    return true;
}

This method returns true even without a body, as it only checks for status codes that are not 202 ACCEPTED and falls back on the Content-Length header.

The EOFException is raised in the following method under IntrospectingClientHttpResponse.hasEmptyMessageBody():

public boolean hasEmptyMessageBody() throws IOException {
    InputStream body = getDelegate().getBody();
    // Per contract body shouldn't be null, but check anyway..
    if (body == null) {
        return true;
    }
    if (body.markSupported()) {
        body.mark(1);
        if (body.read() == -1) { // raises EOFException
            return true;
        }
    //...
}

I was able to workaround this by adding an interceptor to the rest template:

ClientHttpRequestInterceptor addContentLengthFor202Responses = (request, body, execution) -> {
    ClientHttpResponse response = execution.execute(request, body);
    if (response.getStatusCode() == HttpStatus.ACCEPTED) {
        HttpHeaders.writableHttpHeaders(response.getHeaders()).setContentLength(0);
    }
    return response;
};
restTemplate.getInterceptors().add(addContentLengthFor202Responses);

Comment From: bclozel

Can you share a minimal sample application that reproduces the problem? Thanks for the detailed analysis but we could use an actual reproducer as it helps us to better understand the case and our options to fix the situation. Thanks!

Comment From: spring-projects-issues

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

Comment From: spring-projects-issues

Closing due to lack of requested feedback. If you would like us to look at this issue, please provide the requested information and we will re-open the issue.

Comment From: adam-mak

@bclozel Here's a minimal reproducible application (MRA).

empty-message-body-mra.zip

mvn -q -Dtest=MinimalReproducerTest test

It spins up a raw TCP server that returns HTTP/1.1 202 Accepted with no body and no Content-Length header, then calls it with RestTemplate. There are two cases - one that expects a throw and one that implements the workaround and passes.

Comment From: bclozel

Thanks for the sample @adam-mak

We're indeed facing one of the undefined cases here. org.springframework.web.client.IntrospectingClientHttpResponse#hasMessageBody tries to check whether the response has a body, but this is not always possible. According to the RFC, there could be a Transfer-Encoding header, or the server could just write the response and then close the connection. The only way to know... is to try and read the body.

Now we could swallow EOFException here, considering that if we get that exception when reading a single byte of the stream, then the stream is completely empty. Your sample exhibits a different behavior, throwing a:

Caused by: java.net.SocketException: Connection reset
    at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:317)
    at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:345)
    at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:790)
    at java.base/java.net.Socket$SocketInputStream.implRead(Socket.java:983)
    at java.base/java.net.Socket$SocketInputStream.read(Socket.java:970)
    at java.base/java.io.BufferedInputStream.fill(BufferedInputStream.java:289)

I don't think we can ignore that one because we would be hiding errors here.

Which one are you seeing in production, EOFException or SocketException?

Comment From: adam-mak

No worries @bclozel ! I am getting an EOFException once attempting to read the body.

Comment From: NadChel

I'm curious why it throws EOFException instead of returning -1

Is exception stack trace available?

Comment From: github-actions[bot]

Fixed via 0cc79ba3661020cd1dbaf99caac3a604b01ffa41