M. Justin
M. Justin

Reputation: 21239

How can I access the HTTP response content of a Spring Boot default error response?

I have a Spring Boot application that is capturing the HTTP response content of REST requests made to the application. I am doing this for the purposes of logging and future analysis of the requests entering the system.

Currently, I have this implemented as a filter bean (per OncePerRequestFilter), using a ContentCachingResponseWrapper to capture the content written to the output stream:

@Component
public class ResponseBodyLoggingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        ContentCachingResponseWrapper cachingResponse =
                new ContentCachingResponseWrapper(response);

        try {
            filterChain.doFilter(request, cachingResponse);
        } finally {
            byte[] responseBytes = cachingResponse.getContentInputStream().readAllBytes();
            System.out.println("Response: \"" + new String(responseBytes) + "\"");

            cachingResponse.copyBodyToResponse();
        }
    }
}

This works for the majority of requests to the application. One thing it does not capture, however, is the default Spring Boot error response. Rather than capturing the content of the response, it is instead returning the empty string.

build.gradle

plugins {
    id 'org.springframework.boot' version '2.5.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

sourceCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

Controller:

@RestController
@RequestMapping("/test")
public class TestController {
    @GetMapping("/404")
    public void throw404() {
        throw new ResponseStatusException(BAD_REQUEST);
    }
}

HTTP response:

{
  "timestamp": "2021-08-03T18:30:18.934+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/test/404"
}

System output:

Response: ""

I've confirmed that if I switch from the Spring Boot default of embedded Tomcat to embedded Jetty (using spring-boot-starter-jetty and excluding spring-boot-starter-tomcat), this issue still occurs.

How can I capture the Spring Boot error response output within my application? Note that I do not need this to be a filter if another solution solves the problem.

Upvotes: 1

Views: 1898

Answers (2)

jccampanero
jccampanero

Reputation: 53421

I am aware that your question is related with response logging but when it comes to error handling, please, consider the following approach as well, it can complement your code when an error occurs.

As described in the Spring Boot documentation when describing error handling, probably the way to go will be to define a @ControllerAdvice that extends ResponseEntityExceptionHandler and apply it to the controllers you need to.

You can define ExceptionHandlers for your custom exception or override the methods already provided in ResponseEntityExceptionHandler.

For example, you can override the main handleException method :

// will apply for all controllers. The annotation provides attributes for limiting that scope
@ControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {

  @Override
  @ExceptionHandler({
    HttpRequestMethodNotSupportedException.class,
    HttpMediaTypeNotSupportedException.class,
    HttpMediaTypeNotAcceptableException.class,
    MissingPathVariableException.class,
    MissingServletRequestParameterException.class,
    ServletRequestBindingException.class,
    ConversionNotSupportedException.class,
    TypeMismatchException.class,
    HttpMessageNotReadableException.class,
    HttpMessageNotWritableException.class,
    MethodArgumentNotValidException.class,
    MissingServletRequestPartException.class,
    BindException.class,
    NoHandlerFoundException.class,
    AsyncRequestTimeoutException.class
  })
  @Nullable
  public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) throws Exception {
    // Log errors, obtain the required information,...
    // ...

    return super.handleException(ex, request);
  }

}

At the moment, this is the default implementation provided by this method:

/**
  * Provides handling for standard Spring MVC exceptions.
  * @param ex the target exception
  * @param request the current request
  */
@ExceptionHandler({
    HttpRequestMethodNotSupportedException.class,
    HttpMediaTypeNotSupportedException.class,
    HttpMediaTypeNotAcceptableException.class,
    MissingPathVariableException.class,
    MissingServletRequestParameterException.class,
    ServletRequestBindingException.class,
    ConversionNotSupportedException.class,
    TypeMismatchException.class,
    HttpMessageNotReadableException.class,
    HttpMessageNotWritableException.class,
    MethodArgumentNotValidException.class,
    MissingServletRequestPartException.class,
    BindException.class,
    NoHandlerFoundException.class,
    AsyncRequestTimeoutException.class
  })
@Nullable
public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) throws Exception {
  HttpHeaders headers = new HttpHeaders();


  if (ex instanceof HttpRequestMethodNotSupportedException) {
    HttpStatus status = HttpStatus.METHOD_NOT_ALLOWED;
    return handleHttpRequestMethodNotSupported((HttpRequestMethodNotSupportedException) ex, headers, status, request);
  }
  else if (ex instanceof HttpMediaTypeNotSupportedException) {
    HttpStatus status = HttpStatus.UNSUPPORTED_MEDIA_TYPE;
    return handleHttpMediaTypeNotSupported((HttpMediaTypeNotSupportedException) ex, headers, status, request);
  }
  else if (ex instanceof HttpMediaTypeNotAcceptableException) {
    HttpStatus status = HttpStatus.NOT_ACCEPTABLE;
    return handleHttpMediaTypeNotAcceptable((HttpMediaTypeNotAcceptableException) ex, headers, status, request);
  }
  else if (ex instanceof MissingPathVariableException) {
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    return handleMissingPathVariable((MissingPathVariableException) ex, headers, status, request);
  }
  else if (ex instanceof MissingServletRequestParameterException) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    return handleMissingServletRequestParameter((MissingServletRequestParameterException) ex, headers, status, request);
  }
  else if (ex instanceof ServletRequestBindingException) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    return handleServletRequestBindingException((ServletRequestBindingException) ex, headers, status, request);
  }
  else if (ex instanceof ConversionNotSupportedException) {
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    return handleConversionNotSupported((ConversionNotSupportedException) ex, headers, status, request);
  }
  else if (ex instanceof TypeMismatchException) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    return handleTypeMismatch((TypeMismatchException) ex, headers, status, request);
  }
  else if (ex instanceof HttpMessageNotReadableException) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    return handleHttpMessageNotReadable((HttpMessageNotReadableException) ex, headers, status, request);
  }
  else if (ex instanceof HttpMessageNotWritableException) {
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    return handleHttpMessageNotWritable((HttpMessageNotWritableException) ex, headers, status, request);
  }
  else if (ex instanceof MethodArgumentNotValidException) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    return handleMethodArgumentNotValid((MethodArgumentNotValidException) ex, headers, status, request);
  }
  else if (ex instanceof MissingServletRequestPartException) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    return handleMissingServletRequestPart((MissingServletRequestPartException) ex, headers, status, request);
  }
  else if (ex instanceof BindException) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    return handleBindException((BindException) ex, headers, status, request);
  }
  else if (ex instanceof NoHandlerFoundException) {
    HttpStatus status = HttpStatus.NOT_FOUND;
    return handleNoHandlerFoundException((NoHandlerFoundException) ex, headers, status, request);
  }
  else if (ex instanceof AsyncRequestTimeoutException) {
    HttpStatus status = HttpStatus.SERVICE_UNAVAILABLE;
    return handleAsyncRequestTimeoutException((AsyncRequestTimeoutException) ex, headers, status, request);
  }
  else {
    // Unknown exception, typically a wrapper with a common MVC exception as cause
    // (since @ExceptionHandler type declarations also match first-level causes):
    // We only deal with top-level MVC exceptions here, so let's rethrow the given
    // exception for further processing through the HandlerExceptionResolver chain.
    throw ex;
  }
}

This SO question can be valuable as well.

This other SO question provides different and alternative approaches that could be interesting, for instance, by extending DispatcherServlet.

Depending on your actual use case, perhaps the httptrace actuator may also meet your requirements by enabling http tracing.

Upvotes: 1

M. Justin
M. Justin

Reputation: 21239

I have not yet determined a good way to achieve the stated goal of getting the Spring Boot error response body within the filter, but after some debugging and diving into the internals of Spring, I believe I may have determined why it isn't working, at least.

It looks like BasicErrorController.error(HttpServletRequest request) is the part of the framework responsible for returning the error object to be rendered.

However, observing where this controller method is called, it appears as if it is happening during the call to Servlet.service() after the actual filtering has taken place. Per tomcat-embed-core's ApplicationFilterChain:

private void internalDoFilter(ServletRequest request,
                              ServletResponse response)
    throws IOException, ServletException {

    // Call the next filter if there is one
    // [...]
    Filter filter = filterConfig.getFilter();
    // [...]
    filter.doFilter(request, response, this);
    // [...]

    // We fell off the end of the chain -- call the servlet instance
    // [...]
    servlet.service(request, response);
    // [...]
}

Per the above code, the ResponseBodyLoggingFilter filter is called in filter.doFilter(request, response, this), but BasicErrorController.error(...) is not called until afterward by servlet.service(request, response).

Upvotes: 1

Related Questions