Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

406 when exception thrown in Spring controller with accept header of text/csv

Tags:

java

rest

spring

I have a controller with a method that returns text/csv. This works fine for normal success cases, but if an exception is thrown, and I have a header of Accept: text/csv, I get a 406 response. For example:

@RequestMapping(value = "/foo", method = RequestMethod.GET, produces = "text/csv")
public String getCsv() {
    throw new IllegalArgumentException();
}

This is in a completely vanilla Spring Boot application (Maven project, importing spring-boot-starter-web-services), consisting of nothing but a controller with the above method.

I assume the reason is that the exception is being converted to a JSON error response by the framework. If I remove the produces attribute and send Accept: */* I get a JSON representation of the exception. Obviously JSON isn't text/csv, hence the 406 (not acceptable) response.

Here's an example of a curl request / response showing the problem:

curl -v http://localhost:8080/foo -H 'accept: text/csv'
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /foo HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> accept: text/csv
> 
< HTTP/1.1 406 
< X-Application-Context: application
< Content-Length: 0
< Date: Sat, 16 Dec 2017 23:04:05 GMT
< 
* Connection #0 to host localhost left intact

However, interestingly, if I look at the /trace endpoint in my Spring application, I see something different:

{
    "timestamp": 1513465445542,
    "info": {
        "method": "GET",
        "path": "/foo",
        "headers": {
            "request": {
                "host": "localhost:8080",
                "user-agent": "curl/7.47.0",
                "accept": "text/csv"
            },
            "response": {
                "X-Application-Context": "application",
                "status": "500"
            }
        },
        "timeTaken": "1"
    }
}

So, Spring thinks it's returning a 500, but when it gets to curl, it's a 406. I see the exact same thing if I send my requests from PostMan.

I'm not sure what's causing the change from 500 to 406. I assume it's not the client, so my best guess is that Tomcat is doing it. Is there any way to stop that from happening? Or is there some other possibility that I'm missing?

like image 949
DaveyDaveDave Avatar asked Dec 15 '17 11:12

DaveyDaveDave


2 Answers

==== Original Answer (explaining the expected behavior) ====

The Accept header specifies the format type which client expects the server to respond with. Any variance to this leads to HTTP 406 - Not Acceptable error. This error however does not means that the operation failed, however it indicates that the client expectation failed for the specified format.

In your case the Accept header carries text/csv but server responds with application/json, thus the 406 error because there is a clear mismatch.

To correct this behavior there is no change required on server / spring end. Instead the client should start sending Accept header which will carry value as application/json,text/csv. This will ensure that client expects both formats and supports them in case of valid / error response.

Refer here for more details.

Edit 22nd Dec, 2017

The observed behavior is confirmed as a bug by Spring team here. No known workaround available yet.

Edit 04th Jan, 2018

As mentioned in Spring JIRA comments as a workaround we need to remove the HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE request attribute in @RestControllerAdvice. The code could look something like below (returns a 500 with some "info" -- a serialized version of the object is also returned).

Rest controller Advice

@RestControllerAdvice
public class ExampleControllerAdvice {

    @ExceptionHandler(value = Exception.class)
    public ResponseEntity<ErrorResponse> handleException(HttpServletRequest request, Exception e) {
        ErrorResponse response = new ErrorResponse();
        response.setErrorMsg("Server error " + e); // or whatever you want
        response.setErrorCode("ERROR007"); // or whatever you want
        request.removeAttribute(
                  HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);

        return new ResponseEntity<ErrorResponse>(response, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

ErrorResponse object

public class ErrorResponse {

    private String errorCode;
    private String errorMsg;

    public String getErrorCode() {
        return errorCode;
    }

    public void setErrorCode(String errorCode) {
        this.errorCode = errorCode;
    }

    public String getErrorMsg() {
        return errorMsg;
    }

    public void setErrorMsg(String errorMsg) {
        this.errorMsg = errorMsg;
    }

}

Edit 27th Jun, 2019

This is fixed now in Spring Framework. The request attribute HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE now removed automatically by Spring before handling an exception.

like image 91
Bond - Java Bond Avatar answered Nov 14 '22 07:11

Bond - Java Bond


I've also seen this problem when using spring-hateoas to return a VndErrors.VndError from a global exception handler.

The root cause is that the generated response passes through the writeWithMessageConverters method in the AbstractMessageConverterMethodProcessor class and the logic in there ends up selecting the first content type from the produces array and cycles through its message converters looking for something that can convert it to that type.

To make sure we pass that logic, a json content type must be first in that array so that the Jackson HTTP message converter can convert the error:

@GetMapping(value = "/foo", produces = { MediaType.APPLICATION_JSON_UTF8_VALUE, "text/csv" } )
public ResponseEntity<String> getCsv() {

  if(hasItFailed()) {    
    throw new IllegalArgumentException();
  }

  return ResponseEntity
            .ok()
            .header(HttpHeaders.CONTENT_TYPE, "text/csv")
            .body("it worked!");
}

Now we're left with the problem of legitimate responses - in your case text/csv. To make sure those don't end up with a content type of json you have to return a ResponseEntity and set the content type header. The logic in Spring's writeWithMessageConverters method will look for it and use it.

NB: this is based on spring boot 2

like image 41
Andy Brown Avatar answered Nov 14 '22 08:11

Andy Brown