diff --git a/core/src/main/java/com/adobe/aio/exception/feign/RetryAfterError.java b/core/src/main/java/com/adobe/aio/exception/feign/RetryAfterError.java new file mode 100644 index 00000000..b2b786b7 --- /dev/null +++ b/core/src/main/java/com/adobe/aio/exception/feign/RetryAfterError.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package com.adobe.aio.exception.feign; + +import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME; + +import feign.FeignException; +import feign.Response; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RetryAfterError extends FeignException { + + private static final Logger logger = LoggerFactory.getLogger(RetryAfterError.class); + private static final String ERROR_MESSAGE_TEMPLATE = "Rate limit exceeded. request-id: `%s`, retry-after: `%s` seconds. %s"; + private final String upStreamRequestId; + private final String retryAfter; + + public RetryAfterError(Response response, FeignException exception, String requestId, + String retryAfter) { + super(response.status(), + String.format(ERROR_MESSAGE_TEMPLATE, requestId, retryAfter, exception.getMessage()), + response.request(), exception); + this.upStreamRequestId = requestId; + this.retryAfter = retryAfter; + } + + public Optional getUpStreamRequestId() { + return Optional.ofNullable(upStreamRequestId); + } + /** + * Get the retry-after value in seconds + * + * @return the retry-after value in seconds, or 0 if not available or invalid + */ + public long getRetryAfterInSeconds() { + if (retryAfter == null || retryAfter.trim().isEmpty()) { + return 0L; + } + + String trimmedRetryAfterHeaderValue = retryAfter.trim(); + + // First, try to parse as a number of seconds (delay-seconds) + try { + return Long.parseLong(trimmedRetryAfterHeaderValue); + } catch (NumberFormatException e) { + // If not a number, try to parse as HTTP-date + try { + // Parse HTTP-date format (e.g., "Tue, 3 Jun 2008 11:05:30 GMT") + Instant retryInstant = Instant.from(RFC_1123_DATE_TIME.parse(trimmedRetryAfterHeaderValue)); + Instant now = Instant.now(); + long secondsUntilRetry = retryInstant.getEpochSecond() - now.getEpochSecond(); + return Math.max(0L, secondsUntilRetry); // Ensure non-negative + } catch (DateTimeParseException dateTimeParseException) { + logger.warn("Invalid retry-after header value (neither delay-seconds nor HTTP-date): {}", + retryAfter, dateTimeParseException); + return 0L; + } + } + } +} diff --git a/core/src/main/java/com/adobe/aio/util/feign/IOErrorDecoder.java b/core/src/main/java/com/adobe/aio/util/feign/IOErrorDecoder.java index ac9f3c5f..5fc9ace3 100644 --- a/core/src/main/java/com/adobe/aio/util/feign/IOErrorDecoder.java +++ b/core/src/main/java/com/adobe/aio/util/feign/IOErrorDecoder.java @@ -12,6 +12,7 @@ package com.adobe.aio.util.feign; import com.adobe.aio.exception.feign.IOUpstreamError; +import com.adobe.aio.exception.feign.RetryAfterError; import feign.FeignException; import feign.Response; import feign.codec.ErrorDecoder; @@ -23,24 +24,41 @@ public class IOErrorDecoder implements ErrorDecoder { public static final String REQUEST_ID = "x-request-id"; + public static final String RETRY_AFTER_HEADER = "retry-after"; private final Logger logger = LoggerFactory.getLogger(this.getClass()); @Override public Exception decode(String methodKey, Response response) { FeignException exception = FeignException.errorStatus(methodKey, response); - logger.warn("Upstream response error ({},{})", response.status(), exception.contentUTF8()); - return (response.status() >= 500) ? - new IOUpstreamError(response, exception, getRequestId(response.headers())) : exception; + String content = exception.contentUTF8(); + logger.warn("Upstream response error ({},{})", response.status(), content); + + // Handle rate limiting (429) responses specially + if (response.status() == 429) { + return new RetryAfterError(response, exception, getRequestId(response.headers()), + getRetryAfterHeaderValue(response.headers())); + } + + return (response.status() >= 500) ? new IOUpstreamError(response, exception, + getRequestId(response.headers())) : exception; } private String getRequestId(Map> headers) { try { return headers.get(REQUEST_ID).iterator().next(); } catch (Exception e) { - logger.warn("The upstream Error response does not hold any {} header", REQUEST_ID, - e.getMessage()); + logger.warn("The upstream Error response does not hold any {} header", REQUEST_ID, e); return "NA"; } } + + private String getRetryAfterHeaderValue(Map> headers) { + try { + return headers.get(RETRY_AFTER_HEADER).iterator().next(); + } catch (Exception e) { + logger.warn("The upstream Error response does not hold any {} header", RETRY_AFTER_HEADER, e); + return null; + } + } } diff --git a/core/src/test/java/com/adobe/aio/exception/feign/RetryAfterErrorTest.java b/core/src/test/java/com/adobe/aio/exception/feign/RetryAfterErrorTest.java new file mode 100644 index 00000000..08c86962 --- /dev/null +++ b/core/src/test/java/com/adobe/aio/exception/feign/RetryAfterErrorTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package com.adobe.aio.exception.feign; + +import static com.adobe.aio.util.feign.FeignTestUtils.DEFAULT_RETRY_AFTER_SECONDS_STR; +import static com.adobe.aio.util.feign.FeignTestUtils.create429Response; +import static com.adobe.aio.util.feign.FeignTestUtils.createResponseWithEmptyHeaders; +import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import feign.FeignException; +import feign.Response; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class RetryAfterErrorTest { + + private static final String TEST_REQUEST_ID = "test-request-id"; + private static final String TEST_METHOD = "testMethod"; + private static final Logger logger = LoggerFactory.getLogger(RetryAfterErrorTest.class); + + @Test + void testRetryAfterErrorCreation() { + Response response = create429Response(DEFAULT_RETRY_AFTER_SECONDS_STR); + RetryAfterError retryAfterError = createRetryAfterError(response, DEFAULT_RETRY_AFTER_SECONDS_STR); + + // Test basic properties + assertBasicProperties(retryAfterError); + + // Test retry-after functionality (delay-seconds format) + assertEquals(60L, retryAfterError.getRetryAfterInSeconds()); + + // Test request ID + assertRequestIdProperties(retryAfterError); + } + + @Test + void testRetryAfterErrorWithoutRetryAfter() { + Response response = createResponseWithEmptyHeaders(429, "Too Many Requests"); + RetryAfterError retryAfterError = createRetryAfterError(response, null); + + // Test retry-after functionality when not available + assertEquals(0L, retryAfterError.getRetryAfterInSeconds()); + } + + @Test + void testRetryAfterErrorWithInvalidRetryAfter() { + Response response = createResponseWithEmptyHeaders(429, "Too Many Requests"); + RetryAfterError retryAfterError = createRetryAfterError(response, "invalid"); + + // Test retry-after functionality with invalid value + assertEquals(0L, retryAfterError.getRetryAfterInSeconds()); // Should return 0 for invalid values + } + + @Test + void testRetryAfterErrorWithHttpDate() { + Response response = createResponseWithEmptyHeaders(429, "Too Many Requests"); + String httpDate = "Fri, 31 Dec 1999 23:59:59 GMT"; + RetryAfterError retryAfterError = createRetryAfterError(response, httpDate); + + // Test with HTTP-date format (past date) + assertEquals(0L, retryAfterError.getRetryAfterInSeconds()); // Past date should return 0 + } + + @Test + void testRetryAfterErrorWithFutureHttpDate() { + Response response = createResponseWithEmptyHeaders(429, "Too Many Requests"); + // Add a day to get a future date + ZonedDateTime futureDate = ZonedDateTime.now().plusDays(1); + + // Format using RFC 1123 formatter + String formattedFutureDate = futureDate.format(RFC_1123_DATE_TIME); + logger.info("Future date: {}", formattedFutureDate); + + RetryAfterError retryAfterError = createRetryAfterError(response, formattedFutureDate); + + // Should return a positive number of seconds until the future date + long retrySeconds = retryAfterError.getRetryAfterInSeconds(); + assertTrue(retrySeconds > 0, "Should return positive seconds for future date"); + assertEquals(24 * 60 * 60, retrySeconds); + } + + // Helper methods for creating test objects and assertions + + private RetryAfterError createRetryAfterError(Response response, String retryAfter) { + FeignException originalException = FeignException.errorStatus(TEST_METHOD, response); + return new RetryAfterError(response, originalException, TEST_REQUEST_ID, retryAfter); + } + + private void assertBasicProperties(RetryAfterError retryAfterError) { + assertEquals(429, retryAfterError.status()); + assertTrue(retryAfterError.getMessage().contains("Rate limit exceeded")); + assertTrue(retryAfterError.getMessage().contains(TEST_REQUEST_ID)); + assertTrue(retryAfterError.getMessage().contains(DEFAULT_RETRY_AFTER_SECONDS_STR)); + } + + private void assertRequestIdProperties(RetryAfterError retryAfterError) { + assertTrue(retryAfterError.getUpStreamRequestId().isPresent()); + assertEquals(RetryAfterErrorTest.TEST_REQUEST_ID, retryAfterError.getUpStreamRequestId().get()); + } +} diff --git a/core/src/test/java/com/adobe/aio/util/feign/FeignTestUtils.java b/core/src/test/java/com/adobe/aio/util/feign/FeignTestUtils.java new file mode 100644 index 00000000..2f534685 --- /dev/null +++ b/core/src/test/java/com/adobe/aio/util/feign/FeignTestUtils.java @@ -0,0 +1,98 @@ +/* + * Copyright 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package com.adobe.aio.util.feign; + +import feign.Request; +import feign.Response; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Utility class for creating test objects in Feign-related tests + */ +public class FeignTestUtils { + + private static final String DEFAULT_URL = "http://test.com"; + public static final String DEFAULT_REQUEST_ID = "test-request-id"; + public static final String DEFAULT_RETRY_AFTER_SECONDS_STR = "60"; + + private FeignTestUtils() { + // Utility class - prevent instantiation + throw new IllegalStateException("Utility class must not be instantiated"); + } + + /** + * Creates a basic HTTP request for testing + */ + public static Request createBasicRequest() { + return Request.create(Request.HttpMethod.GET, DEFAULT_URL, new HashMap<>(), null, null, null); + } + + /** + * Creates a response builder with common defaults + */ + public static Response.Builder createResponseBuilder(int status, String reason) { + return Response.builder().status(status).reason(reason).request(createBasicRequest()); + } + + /** + * Creates a response with retry-after and request-id headers + */ + public static Response createResponseWithHeaders(int status, String reason, String retryAfter, + String requestId) { + Map> headers = new HashMap<>(); + if (retryAfter != null) { + headers.put("retry-after", Collections.singletonList(retryAfter)); + } + if (requestId != null) { + headers.put("x-request-id", Collections.singletonList(requestId)); + } + + return createResponseBuilder(status, reason).headers(headers).build(); + } + + /** + * Creates a 429 response with retry-after header + */ + public static Response create429Response(String retryAfter) { + return createResponseWithHeaders(429, "Too Many Requests", retryAfter, DEFAULT_REQUEST_ID); + } + + /** + * Creates a 429 response without retry-after header + */ + public static Response create429ResponseWithoutRetryAfter() { + return createResponseWithHeaders(429, "Too Many Requests", null, DEFAULT_REQUEST_ID); + } + + /** + * Creates a 500 response with request-id header + */ + public static Response create500Response() { + return createResponseWithHeaders(500, "Internal Server Error", null, DEFAULT_REQUEST_ID); + } + + /** + * Creates a 400 response without special headers + */ + public static Response create400Response() { + return createResponseBuilder(400, "Bad Request").headers(new HashMap<>()).build(); + } + + /** + * Creates a response with empty headers + */ + public static Response createResponseWithEmptyHeaders(int status, String reason) { + return createResponseBuilder(status, reason).headers(new HashMap<>()).build(); + } +} diff --git a/core/src/test/java/com/adobe/aio/util/feign/IOErrorDecoderTest.java b/core/src/test/java/com/adobe/aio/util/feign/IOErrorDecoderTest.java new file mode 100644 index 00000000..23cf168d --- /dev/null +++ b/core/src/test/java/com/adobe/aio/util/feign/IOErrorDecoderTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package com.adobe.aio.util.feign; + +import static com.adobe.aio.util.feign.FeignTestUtils.DEFAULT_REQUEST_ID; +import static com.adobe.aio.util.feign.FeignTestUtils.DEFAULT_RETRY_AFTER_SECONDS_STR; +import static com.adobe.aio.util.feign.FeignTestUtils.create429Response; +import static com.adobe.aio.util.feign.FeignTestUtils.create429ResponseWithoutRetryAfter; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.adobe.aio.exception.feign.IOUpstreamError; +import com.adobe.aio.exception.feign.RetryAfterError; +import feign.FeignException; +import feign.Response; +import org.junit.jupiter.api.Test; + +class IOErrorDecoderTest { + + private static final String TEST_METHOD = "testMethod"; + private final IOErrorDecoder decoder = new IOErrorDecoder(); + + @Test + void testDecode429ResponseWithRetryAfter() { + Response response = create429Response(DEFAULT_RETRY_AFTER_SECONDS_STR); + Exception exception = decoder.decode(TEST_METHOD, response); + + // Should return a RetryAfterError + assertInstanceOf(RetryAfterError.class, exception); + RetryAfterError retryAfterError = (RetryAfterError) exception; + + assertRetryAfterErrorProperties(retryAfterError, 60L); + } + + @Test + void testDecode429ResponseWithoutRetryAfter() { + Response response = create429ResponseWithoutRetryAfter(); + Exception exception = decoder.decode(TEST_METHOD, response); + + // Should still return a RetryAfterError + assertInstanceOf(RetryAfterError.class, exception); + RetryAfterError retryAfterError = (RetryAfterError) exception; + + assertEquals(429, retryAfterError.status()); + assertEquals(0L, retryAfterError.getRetryAfterInSeconds()); + assertRequestIdPresent(retryAfterError); + } + + @Test + void testDecode500Response() { + Response response = FeignTestUtils.create500Response(); + Exception exception = decoder.decode(TEST_METHOD, response); + + // Should return an IOUpstreamError for 5xx responses + assertInstanceOf(IOUpstreamError.class, exception); + IOUpstreamError upstreamError = (IOUpstreamError) exception; + + assertEquals(500, upstreamError.status()); + assertRequestIdPresent(upstreamError); + } + + @Test + void testDecode400Response() { + Response response = FeignTestUtils.create400Response(); + Exception exception = decoder.decode(TEST_METHOD, response); + + // Should return a regular FeignException for 4xx responses (except 429) + assertInstanceOf(FeignException.class, exception); + assertFalse(exception instanceof RetryAfterError); + assertFalse(exception instanceof IOUpstreamError); + + assertEquals(400, ((FeignException) exception).status()); + } + + // Helper methods for assertions + + private void assertRetryAfterErrorProperties(RetryAfterError retryAfterError, + long expectedSeconds) { + assertEquals(429, retryAfterError.status()); + assertEquals(expectedSeconds, retryAfterError.getRetryAfterInSeconds()); + assertRequestIdPresent(retryAfterError); + } + + private void assertRequestIdPresent(RetryAfterError retryAfterError) { + assertTrue(retryAfterError.getUpStreamRequestId().isPresent()); + assertEquals(DEFAULT_REQUEST_ID, retryAfterError.getUpStreamRequestId().get()); + } + + private void assertRequestIdPresent(IOUpstreamError upstreamError) { + assertTrue(upstreamError.getUpStreamRequestId().isPresent()); + assertEquals(DEFAULT_REQUEST_ID, upstreamError.getUpStreamRequestId().get()); + } +} diff --git a/pom.xml b/pom.xml index 2297f224..dac9255d 100644 --- a/pom.xml +++ b/pom.xml @@ -83,12 +83,12 @@ ${maven.multiModuleProjectDirectory}/copyright_header.txt - 0.8.10 + 0.8.13 1.10.0 3.13.0 5.10.0 - 5.4.0 + 5.19.0 1.7.6 2.13.4 0.11.5 @@ -96,7 +96,7 @@ 1.0.9 - 12.4 + 13.6 3.8.0 @@ -343,7 +343,7 @@ org.apache.maven.plugins maven-release-plugin - 3.0.1 + 3.1.1 false release @@ -471,7 +471,7 @@ org.sonatype.plugins nexus-staging-maven-plugin - 1.6.13 + 1.7.0