FeignClient throws instead of returning ResponseEntity with error http status

后端 未结 2 1927
谎友^
谎友^ 2020-12-18 11:35

As I\'m using ResponseEntity as return value for my FeignClient method, I was expecting it to return a ResponseEntity with 400 status if it\'s what the

相关标签:
2条回答
  • 2020-12-18 12:06

    So, looking at source code, it seams that only solution is actually using feign.Response as return type for FeignClient methods and hand decoding the body with something like new ObjectMapper().readValue(response.body().asReader(), clazz) (with a guard on 2xx status of course because for error statuses, it's very likely that body is an error description and not a valid payload ;).

    This makes possible to extract and forward status, header, body, etc. even if status is not in 2xx range.

    Edit: Here is a way to forward status, headers and mapped JSON body (if possible):

    public static class JsonFeignResponseHelper {
        private final ObjectMapper json = new ObjectMapper();
    
        public <T> Optional<T> decode(Response response, Class<T> clazz) {
            if(response.status() >= 200 && response.status() < 300) {
                try {
                    return Optional.of(json.readValue(response.body().asReader(), clazz));
                } catch(IOException e) {
                    return Optional.empty();
                }
            } else {
                return Optional.empty();
            }
        }
    
        public <T, U> ResponseEntity<U> toResponseEntity(Response response, Class<T> clazz, Function<? super T, ? extends U> mapper) {
            Optional<U> payload = decode(response, clazz).map(mapper);
    
            return new ResponseEntity(
                    payload.orElse(null),//didn't find a way to feed body with original content if payload is empty
                    convertHeaders(response.headers()),
                    HttpStatus.valueOf(response.status()));
        }
    
        public MultiValueMap<String, String>  convertHeaders(Map<String, Collection<String>> responseHeaders) {
            MultiValueMap<String, String> responseEntityHeaders = new LinkedMultiValueMap<>();
            responseHeaders.entrySet().stream().forEach(e -> 
                    responseEntityHeaders.put(e.getKey(), new ArrayList<>(e.getValue())));
            return responseEntityHeaders;
        }
    }
    

    that can be used as follow:

    @PostMapping("/login")
    public ResponseEntity<LoginTokenPair> getTokens(@RequestBody @Valid LoginRequest userCredentials) throws IOException {
        Response response = oauthFeignClient.token();
    
        return feignHelper.toResponseEntity(
                response,
                OauthTokenResponse.class,
                oauthTokenResponse -> new LoginTokenPair(
                        new BearerToken(oauthTokenResponse.access_token, oauthTokenResponse.expires_in),
                        new BearerToken(oauthTokenResponse.refresh_token, refreshTokenValidity)));
    }
    

    This saves headers and status code, but error message is lost :/

    0 讨论(0)
  • 2020-12-18 12:14

    By the way, solution I gave before works, but my initial intention is bad idea: an error is an error and should not be handled on nominal flow. Throwing an exception, like Feign does, and handling it with an @ExceptionHandler is a better way to go in Spring MVC world.

    So two solutions:

    • add an @ExceptionHandler for FeignException
    • configure the FeignClient with an ErrorDecoder to translate the error in an Exception your business layer knows about (and already provide @ExceptionHandler for)

    I prefer second solution because received error message structure is likely to change from a client to an other, so you can extract finer grained data from those error with a per-client error decoding.

    FeignClient with conf (sorry for the noise introduced by feign-form)

    @FeignClient(value = "uaa", configuration = OauthFeignClient.Config.class)
    public interface OauthFeignClient {
    
        @RequestMapping(
                value = "/oauth/token",
                method = RequestMethod.POST,
                consumes = MULTIPART_FORM_DATA_VALUE,
                produces = APPLICATION_JSON_VALUE)
        DefaultOAuth2AccessToken token(Map<String, ?> formParams);
    
        @Configuration
        class Config {
    
            @Value("${oauth.client.password}")
            String oauthClientPassword;
    
            @Autowired
            private ObjectFactory<HttpMessageConverters> messageConverters;
    
            @Bean
            public Encoder feignFormEncoder() {
                return new SpringFormEncoder(new SpringEncoder(messageConverters));
            }
    
            @Bean
            public Decoder springDecoder() {
                return new ResponseEntityDecoder(new SpringDecoder(messageConverters));
            }
    
            @Bean
            public Contract feignContract() {
                return new SpringMvcContract();
            }
    
            @Bean
            public BasicAuthRequestInterceptor basicAuthRequestInterceptor() {
                return new BasicAuthRequestInterceptor("web-client", oauthClientPassword);
            }
    
            @Bean
            public ErrorDecoder uaaErrorDecoder(Decoder decoder) {
                return (methodKey, response) -> {
                    try {
                        OAuth2Exception uaaException = (OAuth2Exception) decoder.decode(response, OAuth2Exception.class);
                        return new SroException(
                                uaaException.getHttpErrorCode(),
                                uaaException.getOAuth2ErrorCode(),
                                Arrays.asList(uaaException.getSummary()));
    
                    } catch (Exception e) {
                        return new SroException(
                                response.status(),
                                "Authorization server responded with " + response.status() + " but failed to parse error payload",
                                Arrays.asList(e.getMessage()));
                    }
                };
            }
        }
    }
    

    Common business exception

    public class SroException extends RuntimeException implements Serializable {
        public final int status;
    
        public final List<String> errors;
    
        public SroException(final int status, final String message, final Collection<String> errors) {
            super(message);
            this.status = status;
            this.errors = Collections.unmodifiableList(new ArrayList<>(errors));
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof SroException)) return false;
            SroException sroException = (SroException) o;
            return status == sroException.status &&
                    Objects.equals(super.getMessage(), sroException.getMessage()) &&
                    Objects.equals(errors, sroException.errors);
        }
    
        @Override
        public int hashCode() {
            return Objects.hash(status, super.getMessage(), errors);
        }
    }
    

    Error handler (extracted from a ResponseEntityExceptionHandler extension)

    @ExceptionHandler({SroException.class})
    public ResponseEntity<Object> handleSroException(SroException ex) {
        return new SroError(ex).toResponse();
    }
    

    Error response DTO

    @XmlRootElement
    public class SroError implements Serializable {
        public final int status;
    
        public final String message;
    
        public final List<String> errors;
    
        public SroError(final int status, final String message, final Collection<String> errors) {
            this.status = status;
            this.message = message;
            this.errors = Collections.unmodifiableList(new ArrayList<>(errors));
        }
    
        public SroError(final SroException e) {
            this.status = e.status;
            this.message = e.getMessage();
            this.errors = Collections.unmodifiableList(e.errors);
        }
    
        protected SroError() {
            this.status = -1;
            this.message = null;
            this.errors = null;
        }
    
        public ResponseEntity<Object> toResponse() {
            return new ResponseEntity(this, HttpStatus.valueOf(this.status));
        }
    
        public ResponseEntity<Object> toResponse(HttpHeaders headers) {
            return new ResponseEntity(this, headers, HttpStatus.valueOf(this.status));
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof SroError)) return false;
            SroError sroException = (SroError) o;
            return status == sroException.status &&
                    Objects.equals(message, sroException.message) &&
                    Objects.equals(errors, sroException.errors);
        }
    
        @Override
        public int hashCode() {
    
            return Objects.hash(status, message, errors);
        }
    }
    

    Feign client usage notice how errors are transparently handled (no try / catch) thanks to @ControllerAdvice & @ExceptionHandler({SroException.class})

    @RestController
    @RequestMapping("/uaa")
    public class AuthenticationController {
        private static final BearerToken REVOCATION_TOKEN = new BearerToken("", 0L);
    
        private final OauthFeignClient oauthFeignClient;
    
        private final int refreshTokenValidity;
    
        @Autowired
        public AuthenticationController(
                OauthFeignClient oauthFeignClient,
                @Value("${oauth.ttl.refresh-token}") int refreshTokenValidity) {
            this.oauthFeignClient = oauthFeignClient;
            this.refreshTokenValidity = refreshTokenValidity;
        }
    
        @PostMapping("/login")
        public ResponseEntity<LoginTokenPair> getTokens(@RequestBody @Valid LoginRequest userCredentials) {
            Map<String, String> formData = new HashMap<>();
            formData.put("grant_type", "password");
            formData.put("client_id", "web-client");
            formData.put("username", userCredentials.username);
            formData.put("password", userCredentials.password);
            formData.put("scope", "openid");
    
            DefaultOAuth2AccessToken response = oauthFeignClient.token(formData);
            return ResponseEntity.ok(new LoginTokenPair(
                    new BearerToken(response.getValue(), response.getExpiresIn()),
                    new BearerToken(response.getRefreshToken().getValue(), refreshTokenValidity)));
        }
    
        @PostMapping("/logout")
        public ResponseEntity<LoginTokenPair> revokeTokens() {
            return ResponseEntity
                    .ok(new LoginTokenPair(REVOCATION_TOKEN, REVOCATION_TOKEN));
        }
    
        @PostMapping("/refresh")
        public ResponseEntity<BearerToken> refreshToken(@RequestHeader("refresh_token") String refresh_token) {
            Map<String, String> formData = new HashMap<>();
            formData.put("grant_type", "refresh_token");
            formData.put("client_id", "web-client");
            formData.put("refresh_token", refresh_token);
            formData.put("scope", "openid");
    
            DefaultOAuth2AccessToken response = oauthFeignClient.token(formData);
            return ResponseEntity.ok(new BearerToken(response.getValue(), response.getExpiresIn()));
        }
    }
    
    0 讨论(0)
提交回复
热议问题