Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Content-Type response header duplicated for failed StreamingResponseBody return value #34366

Closed
rendstrasser opened this issue Feb 4, 2025 · 5 comments
Assignees
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: bug A general bug
Milestone

Comments

@rendstrasser
Copy link

rendstrasser commented Feb 4, 2025

Spring Boot: 3.4.2
Spring Framework: 6.2.2

When setting ResponseEntity#contentType

  • in the GET mapping return value of a REST controller,
  • and the result of the exception handler, ...

... then the Content-Type header is duplicated on the response for a request that fails within StreamingResponseBody#writeTo:

HTTP/1.1 500 Server Error
Date: Tue, 04 Feb 2025 18:29:32 GMT
Vary: Accept-Encoding
Content-Type: application/json
Content-Type: application/json
Content-Length: 0

This might be specific to async requests, like when using StreamingResponseBody.

When debugging locally, I found that ...

... is called multiple times for two different ServletServerHttpResponse objects that hold the same HttpServletResponse. This might be expected, because different ServletServerHttpResponse objects can hold different header values. However, this leads to the content type being duplicated.

The two ServletServerHttpResponse are created at:

Removing the content type at either of the places has a downside:

  • GET mapping: There is no content-type set in the successful case
  • Exception handler: There is no content-type set in the exception case for other APIs which are not async.

Is the duplication of content-type expected here?
Please let me know if more info is required. Thank you in advance, any help is appreciated!

Reproducer:

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

@RestController
@RequestMapping("stream")
@ControllerAdvice
public class StreamingRestApi {

    @GetMapping(produces = "application/json")
    public ResponseEntity<StreamingResponseBody> task() {
        StreamingResponseBody streamingResponseBody = outputStream -> {
            if (true) {
                throw new RuntimeException();
            }
        };

        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(streamingResponseBody);
    }

    @ExceptionHandler
    public ResponseEntity<StreamingResponseBody> handleException(Exception exception) {
        return ResponseEntity.internalServerError()
                .contentType(MediaType.APPLICATION_JSON)
                .build();
    }
}
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Feb 4, 2025
@bclozel bclozel added the in: web Issues in web modules (web, webmvc, webflux, websocket) label Feb 4, 2025
@bclozel
Copy link
Member

bclozel commented Feb 7, 2025

Thanks for reaching out.
For some reason, I cannot reproduce the problem. Using the class you've provided in a Spring Boot application shows the following:

➜  ~ curl http://localhost:8080/streaming -vv
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /streaming HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 500
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Content-Type: application/json
< Content-Length: 0
< Date: Fri, 07 Feb 2025 08:45:41 GMT
< Connection: close
<
* Closing connection

Are you using a specific web server? Another HTTP client?

@bclozel bclozel added the status: waiting-for-feedback We need additional information before we can continue label Feb 7, 2025
@rendstrasser
Copy link
Author

I apologize, I should have compared the different web servers beforehand.

This is interesting. This happens for jetty, but not for tomcat.
Spring Boot sets the content type multiple times in every case, but the HTTP header implementation handles the content type differently between the web servers.

Jetty

org.eclipse.jetty.http.HttpFields.Mutable.Wrapper#add adds every new header to _fields. Note: There is some custom logic for CONTENT_TYPE in org.eclipse.jetty.ee10.servlet.ServletContextResponse.HttpFieldsWrapper#onAddField, but this doesn't prevent the duplicate.

Tomcat

Tomcat implements special handling for the content type. org.apache.catalina.connector.Response#addHeader executes #checkSpecialHeader which performs a set operation instead of an add for the content-type.


I'm not sure which handling is expected. I guess the special handling for content type makes sense, but at the same time this isn't clear from the HttpServletResponse#addHeader documentation.

Here is a full spring boot project reproducer with jetty and the StreamingRestApi.
demo-spring-boot.zip

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Feb 7, 2025
@bclozel bclozel self-assigned this Feb 7, 2025
@bclozel
Copy link
Member

bclozel commented Feb 7, 2025

No worries and thanks for the feedback. I'll have a look.

@bclozel
Copy link
Member

bclozel commented Feb 18, 2025

I think this is related to several issues.
In #31104, we've tried to completely reset the HTTP response in case of errors to start from scratch the error response. This caused issues because pre-existing headers were removed as a result. In #31154 we fell back to clearing the response buffer and leaving headers in place. I think we could refine this behavior by clearing the "Content-Type" response header; we are already clearing the HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE attribute, so this looks like a sensible change to make.

While this looks like a bug and we should in theory fix this in the latest maintenance version (the upcoming 6.2.4), this has some potential for regressions.

Did this duplicate header create behavior issues with proxies, browsers or anything else? Did you just happen to notice this while looking at responses? I'm asking this because if there is no strong reason to a maintenance fix, we could consider this in the upcoming 7.0 version.

@bclozel bclozel added status: waiting-for-feedback We need additional information before we can continue and removed status: feedback-provided Feedback has been provided labels Feb 18, 2025
@rendstrasser
Copy link
Author

We noticed this behavior together with istio. We seem to run into the same issue that was mentioned here: #27887 (comment).

Basically, after going through istio, the Content-Type header value becomes application/json,application/json. In a microservice setup with multiple Spring Boot services calling each other, this then becomes an issue, as the consuming Spring Boot application gets an error when parsing the content type.


We workaround this by not setting the content type if an exception happens within StreamingResponseBody#writeTo. We wrap every exception that is generated in that method into a new StreamingResponseMarkerException and if that is encountered in the exception mapper, we don't set the content type. So, if someone encounters this, there is a best-effort workaround available imo.

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Feb 19, 2025
@bclozel bclozel added type: bug A general bug and removed status: waiting-for-triage An issue we've not yet triaged or decided on status: feedback-provided Feedback has been provided labels Feb 22, 2025
@bclozel bclozel added this to the 6.2.4 milestone Feb 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: bug A general bug
Projects
None yet
Development

No branches or pull requests

3 participants