From bf2ed3909c85c8bbcf02296bd2b611311ef93303 Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Wed, 15 Jul 2026 14:38:16 +0300 Subject: [PATCH 1/9] Honour CC Retry-After header and add randomized retry delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend CloudOperationException with a nullable retryAfterSeconds field so that the error handler can propagate the Retry-After response header to the retry executor. CloudControllerResponseErrorHandler now reads the Retry-After header on 429 responses and passes the parsed value (or null when absent or unparseable) to the exception constructor. ResilientCloudOperationExecutor overrides execute(Supplier) to compute wait time per-attempt: 429 errors honour the header value capped at 120s (or fall back to 60s when the header is absent); subsequent non-429 retries (attempt >= 2) use a random delay in the range 30–90s to spread load. The LongConsumer sleeper field enables test injection without requiring static mocking. TOO_MANY_REQUESTS is added to DEFAULT_STATUSES_TO_IGNORE so the executor retries 429 responses automatically. JIRA:LMCROSSITXSADEPLOY-2653 --- .../multiapps/controller/Messages.java | 3 + .../facade/CloudOperationException.java | 11 ++++ .../CloudControllerResponseErrorHandler.java | 21 ++++++- .../util/ResilientCloudOperationExecutor.java | 55 ++++++++++++++++++- .../util/ResilientOperationExecutor.java | 2 +- 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java index 0a56d6fd46..ec991ba330 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java @@ -4,5 +4,8 @@ public class Messages { // INFO messages public static final String WAITING_MS_BEFORE_RETRYING_WITH_TIMEOUT_OF_MS = "Waiting: {} ms before retrying with timeout of: {} ms"; + public static final String RATE_LIMITED_BY_CC_WAITING_S = "CC returned 429 with Retry-After: {} s. Waiting {} s (capped) before retrying."; + public static final String RATE_LIMITED_BY_CC_NO_HEADER_WAITING_S = "CC returned 429 without Retry-After header. Waiting {} ms before retrying."; + public static final String RANDOM_WAIT_BEFORE_RETRY_S = "Waiting {} ms (randomized) before retrying failed CC operation."; } diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationException.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationException.java index 2f75d0ce85..6a2b2b60b6 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationException.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationException.java @@ -8,6 +8,7 @@ public class CloudOperationException extends CloudException { private final HttpStatus statusCode; private final String statusText; private final String description; + private final Long retryAfterSeconds; public CloudOperationException(HttpStatus statusCode) { this(statusCode, statusCode.getReasonPhrase()); @@ -22,10 +23,16 @@ public CloudOperationException(HttpStatus statusCode, String statusText, String } public CloudOperationException(HttpStatus statusCode, String statusText, String description, Throwable cause) { + this(statusCode, statusText, description, cause, null); + } + + public CloudOperationException(HttpStatus statusCode, String statusText, String description, Throwable cause, + Long retryAfterSeconds) { super(getExceptionMessage(statusCode, statusText, description), cause); this.statusCode = statusCode; this.statusText = statusText; this.description = description; + this.retryAfterSeconds = retryAfterSeconds; } private static String getExceptionMessage(HttpStatus statusCode, String statusText, String description) { @@ -47,4 +54,8 @@ public String getDescription() { return description; } + public Long getRetryAfterSeconds() { + return retryAfterSeconds; + } + } diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java index 006e6051f4..2dd8364184 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java @@ -19,6 +19,7 @@ private static CloudOperationException getException(ClientHttpResponse response) HttpStatus statusCode = HttpStatus.valueOf(response.getStatusCode() .value()); String statusText = response.getStatusText(); + Long retryAfterSeconds = extractRetryAfterSeconds(response, statusCode); ObjectMapper mapper = new ObjectMapper(); // can reuse, share globally @@ -26,12 +27,28 @@ private static CloudOperationException getException(ClientHttpResponse response) try { @SuppressWarnings("unchecked") Map responseBody = mapper.readValue(response.getBody(), Map.class); String description = getTrimmedDescription(responseBody); - return new CloudOperationException(statusCode, statusText, description); + return new CloudOperationException(statusCode, statusText, description, null, retryAfterSeconds); } catch (IOException e) { // Fall through. Handled below. } } - return new CloudOperationException(statusCode, statusText); + return new CloudOperationException(statusCode, statusText, null, null, retryAfterSeconds); + } + + private static Long extractRetryAfterSeconds(ClientHttpResponse response, HttpStatus statusCode) { + if (statusCode != HttpStatus.TOO_MANY_REQUESTS) { + return null; + } + String headerValue = response.getHeaders() + .getFirst(org.springframework.http.HttpHeaders.RETRY_AFTER); + if (headerValue == null) { + return null; + } + try { + return Long.parseLong(headerValue); + } catch (NumberFormatException e) { + return null; + } } private static String getTrimmedDescription(Map responseBody) { diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java index 54bae27e32..1318ac6006 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java @@ -7,7 +7,10 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; +import java.util.function.LongConsumer; +import java.util.function.Supplier; import org.apache.commons.collections4.SetUtils; import org.cloudfoundry.multiapps.common.util.MiscUtil; @@ -23,7 +26,8 @@ public class ResilientCloudOperationExecutor extends ResilientOperationExecutor private static final Set DEFAULT_STATUSES_TO_IGNORE = Set.of(HttpStatus.GATEWAY_TIMEOUT, HttpStatus.REQUEST_TIMEOUT, HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.BAD_GATEWAY, - HttpStatus.SERVICE_UNAVAILABLE); + HttpStatus.SERVICE_UNAVAILABLE, + HttpStatus.TOO_MANY_REQUESTS); private static final int DEFAULT_TIMEOUT_RETRY_WAIT_TIME_IN_MILLIS = 30 * 1000; // 30 seconds @@ -31,7 +35,13 @@ public class ResilientCloudOperationExecutor extends ResilientOperationExecutor Duration.ofMinutes(8), 3, Duration.ofMinutes(15)); + private static final long RATE_LIMIT_RETRY_AFTER_CAP_IN_SECONDS = 120L; + private static final long RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS = 60_000L; + private static final long RANDOM_RETRY_MIN_WAIT_IN_MILLIS = 30_000L; + private static final long RANDOM_RETRY_MAX_WAIT_IN_MILLIS = 90_000L; + private Set additionalStatusesToIgnore = Collections.emptySet(); + private LongConsumer sleeper = MiscUtil::sleep; @Override public ResilientCloudOperationExecutor withRetryCount(long retryCount) { @@ -48,6 +58,25 @@ public ResilientCloudOperationExecutor withStatusesToIgnore(HttpStatus... status return this; } + ResilientCloudOperationExecutor withSleeper(LongConsumer sleeper) { + this.sleeper = sleeper; + return this; + } + + @Override + public T execute(Supplier operation) { + for (int i = 1; i < retryCount; i++) { + try { + return operation.get(); + } catch (RuntimeException e) { + handle(e); + long waitMillis = computeWaitMillis(e, i); + sleeper.accept(waitMillis); + } + } + return operation.get(); + } + public T executeWithExponentialBackoff(Function operation) { int waitTimeBetweenRetriesInMillis = DEFAULT_TIMEOUT_RETRY_WAIT_TIME_IN_MILLIS; int retryIndex = 1; @@ -87,6 +116,30 @@ protected void handle(CloudOperationException e) { e); } + private long computeWaitMillis(RuntimeException e, int attemptIndex) { + if (e instanceof CloudOperationException) { + CloudOperationException cloudException = (CloudOperationException) e; + if (cloudException.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) { + Long retryAfterSeconds = cloudException.getRetryAfterSeconds(); + if (retryAfterSeconds != null) { + long cappedSeconds = Math.min(retryAfterSeconds, RATE_LIMIT_RETRY_AFTER_CAP_IN_SECONDS); + long waitMillis = cappedSeconds * 1000L; + LOGGER.info(Messages.RATE_LIMITED_BY_CC_WAITING_S, retryAfterSeconds, cappedSeconds); + return waitMillis; + } + LOGGER.info(Messages.RATE_LIMITED_BY_CC_NO_HEADER_WAITING_S, RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS); + return RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS; + } + } + if (attemptIndex >= 2) { + long waitMillis = ThreadLocalRandom.current() + .nextLong(RANDOM_RETRY_MIN_WAIT_IN_MILLIS, RANDOM_RETRY_MAX_WAIT_IN_MILLIS + 1); + LOGGER.info(Messages.RANDOM_WAIT_BEFORE_RETRY_S, waitMillis); + return waitMillis; + } + return waitTimeBetweenRetriesInMillis; + } + private boolean shouldRetry(CloudOperationException e) { return getStatusesToIgnore().contains(e.getStatusCode()); } diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientOperationExecutor.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientOperationExecutor.java index bc536a45cc..2adb49cbb3 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientOperationExecutor.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientOperationExecutor.java @@ -16,7 +16,7 @@ public class ResilientOperationExecutor { private static final long DEFAULT_RETRY_COUNT = 3; private static final long DEFAULT_WAIT_TIME_BETWEEN_RETRIES_IN_MILLIS = 5000; - private long waitTimeBetweenRetriesInMillis = DEFAULT_WAIT_TIME_BETWEEN_RETRIES_IN_MILLIS; + protected long waitTimeBetweenRetriesInMillis = DEFAULT_WAIT_TIME_BETWEEN_RETRIES_IN_MILLIS; protected long retryCount = DEFAULT_RETRY_COUNT; public ResilientOperationExecutor withRetryCount(long retryCount) { From 8ac83a0f36ff073644e564d3fc84c242bcdcbc9b Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Wed, 15 Jul 2026 14:40:17 +0300 Subject: [PATCH 2/9] Add tests for Retry-After and randomized delay behaviour CloudOperationExceptionTest: verify retryAfterSeconds is null via existing constructors and stored correctly via the new five-arg form. CloudControllerResponseErrorHandlerTest: fix the ClientHttpResponseMock getHeaders() stub (was UnsupportedOperationException) and add four cases covering 429 with/without/unparseable Retry-After and non-429 headers. ResilientCloudOperationExecutorTest: add five new tests covering the Retry-After cap, header-absent fallback, fixed delay for non-429 first retry, and deterministic random delay from the second retry onward. Uses withSleeper and withRandomDelaySupplier testability seams to avoid real sleeps and non-deterministic assertions. Also adds withRandomDelaySupplier package-private builder to ResilientCloudOperationExecutor to support deterministic test seeding. JIRA:LMCROSSITXSADEPLOY-2653 --- .../util/ResilientCloudOperationExecutor.java | 12 +- .../facade/CloudOperationExceptionTest.java | 17 +++ ...oudControllerResponseErrorHandlerTest.java | 62 +++++++++- .../ResilientCloudOperationExecutorTest.java | 109 ++++++++++++++++++ 4 files changed, 197 insertions(+), 3 deletions(-) diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java index 1318ac6006..8f2b5f681a 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java @@ -10,6 +10,7 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; import java.util.function.LongConsumer; +import java.util.function.LongSupplier; import java.util.function.Supplier; import org.apache.commons.collections4.SetUtils; @@ -42,6 +43,9 @@ public class ResilientCloudOperationExecutor extends ResilientOperationExecutor private Set additionalStatusesToIgnore = Collections.emptySet(); private LongConsumer sleeper = MiscUtil::sleep; + private LongSupplier randomDelaySupplier = () -> ThreadLocalRandom.current() + .nextLong(RANDOM_RETRY_MIN_WAIT_IN_MILLIS, + RANDOM_RETRY_MAX_WAIT_IN_MILLIS + 1); @Override public ResilientCloudOperationExecutor withRetryCount(long retryCount) { @@ -63,6 +67,11 @@ ResilientCloudOperationExecutor withSleeper(LongConsumer sleeper) { return this; } + ResilientCloudOperationExecutor withRandomDelaySupplier(LongSupplier randomDelaySupplier) { + this.randomDelaySupplier = randomDelaySupplier; + return this; + } + @Override public T execute(Supplier operation) { for (int i = 1; i < retryCount; i++) { @@ -132,8 +141,7 @@ private long computeWaitMillis(RuntimeException e, int attemptIndex) { } } if (attemptIndex >= 2) { - long waitMillis = ThreadLocalRandom.current() - .nextLong(RANDOM_RETRY_MIN_WAIT_IN_MILLIS, RANDOM_RETRY_MAX_WAIT_IN_MILLIS + 1); + long waitMillis = randomDelaySupplier.getAsLong(); LOGGER.info(Messages.RANDOM_WAIT_BEFORE_RETRY_S, waitMillis); return waitMillis; } diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationExceptionTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationExceptionTest.java index 8319f74929..438c07a6a2 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationExceptionTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationExceptionTest.java @@ -42,4 +42,21 @@ void testCauseIsPropagatedToSuper() { Assertions.assertSame(cause, e.getCause()); } + + @Test + void testRetryAfterSecondsNullByDefault() { + CloudOperationException e = new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS); + + Assertions.assertNull(e.getRetryAfterSeconds()); + } + + @Test + void testRetryAfterSecondsStoredWhenProvided() { + CloudOperationException e = new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), + null, null, 60L); + + Assertions.assertEquals(60L, e.getRetryAfterSeconds()); + } } + diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java index 0fb08bc9cd..af112b4af4 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java @@ -1,6 +1,7 @@ package org.cloudfoundry.multiapps.controller.client.facade.rest; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; import java.io.ByteArrayInputStream; @@ -56,6 +57,58 @@ void testWithEmptyResponse() throws IOException { testWithError(response, expectedException); } + @Test + void testWith429AndRetryAfterHeader() throws IOException { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.RETRY_AFTER, "60"); + ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{}"), headers); + try { + handler.handleError(response); + fail("Expected an exception"); + } catch (CloudOperationException e) { + assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); + assertEquals(60L, e.getRetryAfterSeconds()); + } + } + + @Test + void testWith429WithoutRetryAfterHeader() throws IOException { + ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{}")); + try { + handler.handleError(response); + fail("Expected an exception"); + } catch (CloudOperationException e) { + assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); + assertNull(e.getRetryAfterSeconds()); + } + } + + @Test + void testWith429AndNonNumericRetryAfterHeader() throws IOException { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.RETRY_AFTER, "not-a-number"); + ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{}"), headers); + try { + handler.handleError(response); + fail("Expected an exception"); + } catch (CloudOperationException e) { + assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); + assertNull(e.getRetryAfterSeconds()); + } + } + + @Test + void testNon429DoesNotSetRetryAfterSeconds() throws IOException { + ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.INTERNAL_SERVER_ERROR, toInputStream("{}")); + try { + handler.handleError(response); + fail("Expected an exception"); + } catch (CloudOperationException e) { + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, e.getStatusCode()); + assertNull(e.getRetryAfterSeconds()); + } + } + private void testWithError(ClientHttpResponseMock response, CloudOperationException expectedException) throws IOException { try { handler.handleError(response); @@ -87,10 +140,16 @@ private static class ClientHttpResponseMock implements ClientHttpResponse { private final HttpStatus statusCode; private final InputStream body; + private final HttpHeaders headers; public ClientHttpResponseMock(HttpStatus statusCode, InputStream body) { + this(statusCode, body, new HttpHeaders()); + } + + public ClientHttpResponseMock(HttpStatus statusCode, InputStream body, HttpHeaders headers) { this.statusCode = statusCode; this.body = body; + this.headers = headers; } @Override @@ -100,7 +159,7 @@ public InputStream getBody() { @Override public HttpHeaders getHeaders() { - throw new UnsupportedOperationException(); + return headers; } @Override @@ -126,3 +185,4 @@ public void close() { } } + diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java index 42ef66f1b9..ae5be115b6 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java @@ -1,5 +1,7 @@ package org.cloudfoundry.multiapps.controller.client.util; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; @@ -73,4 +75,111 @@ void testFluentBuildersReturnSameTypeForChaining() { .withStatusesToIgnore(HttpStatus.I_AM_A_TEAPOT); Assertions.assertNotNull(chained); } + + @Test + void testRetryAfterHeaderIsHonouredOnRateLimit() { + List sleepCalls = new ArrayList<>(); + executor = new ResilientCloudOperationExecutor().withRetryCount(3) + .withSleeper(sleepCalls::add); + AtomicInteger attempts = new AtomicInteger(); + Supplier operation = () -> { + if (attempts.incrementAndGet() == 1) { + throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), + null, null, 2L); + } + return "ok"; + }; + + executor.execute(operation); + + Assertions.assertEquals(1, sleepCalls.size()); + Assertions.assertEquals(2_000L, sleepCalls.get(0)); + } + + @Test + void testRetryAfterHeaderCappedAtMaximum() { + List sleepCalls = new ArrayList<>(); + executor = new ResilientCloudOperationExecutor().withRetryCount(3) + .withSleeper(sleepCalls::add); + AtomicInteger attempts = new AtomicInteger(); + Supplier operation = () -> { + if (attempts.incrementAndGet() == 1) { + throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), + null, null, 300L); + } + return "ok"; + }; + + executor.execute(operation); + + Assertions.assertEquals(1, sleepCalls.size()); + Assertions.assertEquals(120_000L, sleepCalls.get(0)); + } + + @Test + void testRateLimitFallbackWhenRetryAfterAbsent() { + List sleepCalls = new ArrayList<>(); + executor = new ResilientCloudOperationExecutor().withRetryCount(3) + .withSleeper(sleepCalls::add); + AtomicInteger attempts = new AtomicInteger(); + Supplier operation = () -> { + if (attempts.incrementAndGet() == 1) { + throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), + null, null, null); + } + return "ok"; + }; + + executor.execute(operation); + + Assertions.assertEquals(1, sleepCalls.size()); + Assertions.assertEquals(60_000L, sleepCalls.get(0)); + } + + @Test + void testNon429UsesExistingFixedDelay() { + List sleepCalls = new ArrayList<>(); + executor = new ResilientCloudOperationExecutor().withRetryCount(3) + .withWaitTimeBetweenRetriesInMillis(5_000L) + .withSleeper(sleepCalls::add); + AtomicInteger attempts = new AtomicInteger(); + Supplier operation = () -> { + if (attempts.incrementAndGet() == 1) { + throw new CloudOperationException(HttpStatus.BAD_GATEWAY); + } + return "ok"; + }; + + executor.execute(operation); + + Assertions.assertEquals(1, sleepCalls.size()); + Assertions.assertEquals(5_000L, sleepCalls.get(0)); + } + + @Test + void testRandomDelayAppliedForNon429AfterFirstRetry() { + long deterministicDelay = 45_000L; + List sleepCalls = new ArrayList<>(); + executor = new ResilientCloudOperationExecutor().withRetryCount(4) + .withWaitTimeBetweenRetriesInMillis(0) + .withSleeper(sleepCalls::add) + .withRandomDelaySupplier(() -> deterministicDelay); + AtomicInteger attempts = new AtomicInteger(); + Supplier operation = () -> { + if (attempts.incrementAndGet() < 3) { + throw new CloudOperationException(HttpStatus.INTERNAL_SERVER_ERROR); + } + return "ok"; + }; + + executor.execute(operation); + + Assertions.assertEquals(2, sleepCalls.size()); + Assertions.assertEquals(0L, sleepCalls.get(0)); + Assertions.assertEquals(deterministicDelay, sleepCalls.get(1)); + } } + From 5ff032f8451084cecb117f4bd06a11e0d2621926 Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Wed, 15 Jul 2026 14:44:51 +0300 Subject: [PATCH 3/9] Add gap-case tests for Retry-After and retry executor behaviour Cover Retry-After cap boundary, retry exhaustion, Runnable-overload routing, 429 body+header passthrough, and four-arg constructor null retry-after. JIRA:LMCROSSITXSADEPLOY-2653 --- .../facade/CloudOperationExceptionTest.java | 11 ++++ ...oudControllerResponseErrorHandlerTest.java | 16 +++++ .../ResilientCloudOperationExecutorTest.java | 59 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationExceptionTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationExceptionTest.java index 438c07a6a2..65b2faf73d 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationExceptionTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/CloudOperationExceptionTest.java @@ -58,5 +58,16 @@ void testRetryAfterSecondsStoredWhenProvided() { Assertions.assertEquals(60L, e.getRetryAfterSeconds()); } + + @Test + void testFourArgConstructorLeavesRetryAfterSecondsNull() { + Throwable cause = new RuntimeException("boom"); + + CloudOperationException e = new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), "throttled", cause); + + Assertions.assertNull(e.getRetryAfterSeconds()); + Assertions.assertSame(cause, e.getCause()); + } } diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java index af112b4af4..fd0eec94fb 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java @@ -109,6 +109,22 @@ void testNon429DoesNotSetRetryAfterSeconds() throws IOException { } } + @Test + void testWith429RetainsRetryAfterAndDescriptionWhenBodyPresent() throws IOException { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.RETRY_AFTER, "30"); + ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, + toInputStream("{\"description\":\"rate limit exceeded\"}"), headers); + try { + handler.handleError(response); + fail("Expected an exception"); + } catch (CloudOperationException e) { + assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); + assertEquals(30L, e.getRetryAfterSeconds()); + assertEquals("rate limit exceeded", e.getDescription()); + } + } + private void testWithError(ClientHttpResponseMock response, CloudOperationException expectedException) throws IOException { try { handler.handleError(response); diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java index ae5be115b6..235e4f5443 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java @@ -118,6 +118,27 @@ void testRetryAfterHeaderCappedAtMaximum() { Assertions.assertEquals(120_000L, sleepCalls.get(0)); } + @Test + void testRetryAfterHeaderAtCapIsNotReduced() { + List sleepCalls = new ArrayList<>(); + executor = new ResilientCloudOperationExecutor().withRetryCount(3) + .withSleeper(sleepCalls::add); + AtomicInteger attempts = new AtomicInteger(); + Supplier operation = () -> { + if (attempts.incrementAndGet() == 1) { + throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), + null, null, 120L); + } + return "ok"; + }; + + executor.execute(operation); + + Assertions.assertEquals(1, sleepCalls.size()); + Assertions.assertEquals(120_000L, sleepCalls.get(0)); + } + @Test void testRateLimitFallbackWhenRetryAfterAbsent() { List sleepCalls = new ArrayList<>(); @@ -181,5 +202,43 @@ void testRandomDelayAppliedForNon429AfterFirstRetry() { Assertions.assertEquals(0L, sleepCalls.get(0)); Assertions.assertEquals(deterministicDelay, sleepCalls.get(1)); } + + @Test + void testRetryableOperationThatNeverSucceedsIsEventuallyRethrown() { + List sleepCalls = new ArrayList<>(); + executor = new ResilientCloudOperationExecutor().withRetryCount(3) + .withWaitTimeBetweenRetriesInMillis(0) + .withSleeper(sleepCalls::add) + .withRandomDelaySupplier(() -> 0L); + AtomicInteger attempts = new AtomicInteger(); + Supplier operation = () -> { + attempts.incrementAndGet(); + throw new CloudOperationException(HttpStatus.BAD_GATEWAY); + }; + + Assertions.assertThrows(CloudOperationException.class, () -> executor.execute(operation)); + Assertions.assertEquals(3, attempts.get()); + Assertions.assertEquals(2, sleepCalls.size()); + } + + @Test + void testRunnableOverloadIsRetriedThroughOverriddenExecute() { + List sleepCalls = new ArrayList<>(); + executor = new ResilientCloudOperationExecutor().withRetryCount(3) + .withWaitTimeBetweenRetriesInMillis(7_000L) + .withSleeper(sleepCalls::add); + AtomicInteger attempts = new AtomicInteger(); + Runnable operation = () -> { + if (attempts.incrementAndGet() < 2) { + throw new CloudOperationException(HttpStatus.SERVICE_UNAVAILABLE); + } + }; + + executor.execute(operation); + + Assertions.assertEquals(2, attempts.get()); + Assertions.assertEquals(1, sleepCalls.size()); + Assertions.assertEquals(7_000L, sleepCalls.get(0)); + } } From 916049fa6a67bddc46efc024a666c744ed7f8567 Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Wed, 15 Jul 2026 14:56:37 +0300 Subject: [PATCH 4/9] Reject non-positive Retry-After and enforce 1s minimum wait Negative or zero Retry-After values caused either an IllegalArgumentException in Thread.sleep (negative case) or a busy-wait tight loop against the CC (zero case). Fix both: - extractRetryAfterSeconds now returns null for values <= 0 - computeWaitMillis floors cappedSeconds at 1 via Math.max(1L, ...) JIRA:LMCROSSITXSADEPLOY-2653 --- .../facade/rest/CloudControllerResponseErrorHandler.java | 3 ++- .../client/util/ResilientCloudOperationExecutor.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java index 2dd8364184..27b806332f 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java @@ -45,7 +45,8 @@ private static Long extractRetryAfterSeconds(ClientHttpResponse response, HttpSt return null; } try { - return Long.parseLong(headerValue); + long parsed = Long.parseLong(headerValue); + return parsed > 0 ? parsed : null; } catch (NumberFormatException e) { return null; } diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java index 8f2b5f681a..fa0c3414c2 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java @@ -131,7 +131,7 @@ private long computeWaitMillis(RuntimeException e, int attemptIndex) { if (cloudException.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) { Long retryAfterSeconds = cloudException.getRetryAfterSeconds(); if (retryAfterSeconds != null) { - long cappedSeconds = Math.min(retryAfterSeconds, RATE_LIMIT_RETRY_AFTER_CAP_IN_SECONDS); + long cappedSeconds = Math.max(1L, Math.min(retryAfterSeconds, RATE_LIMIT_RETRY_AFTER_CAP_IN_SECONDS)); long waitMillis = cappedSeconds * 1000L; LOGGER.info(Messages.RATE_LIMITED_BY_CC_WAITING_S, retryAfterSeconds, cappedSeconds); return waitMillis; From aa1477d496409eade9711c354b9089fdd1dcd58b Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Thu, 16 Jul 2026 08:24:21 +0300 Subject: [PATCH 5/9] Fix catch clause, loop type, and computeWaitMillis signature - Narrow catch in execute() from RuntimeException to CloudOperationException so non-retriable exceptions (NPE, ISE) are not silently swallowed - Promote loop counter i from int to long to match the protected long retryCount field and avoid integer overflow on large retry counts - Promote computeWaitMillis attemptIndex parameter from int to long to match the updated loop variable without a narrowing cast JIRA:LMCROSSITXSADEPLOY-2653 --- .../client/util/ResilientCloudOperationExecutor.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java index fa0c3414c2..6550076c1b 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java @@ -74,10 +74,10 @@ ResilientCloudOperationExecutor withRandomDelaySupplier(LongSupplier randomDelay @Override public T execute(Supplier operation) { - for (int i = 1; i < retryCount; i++) { + for (long i = 1; i < retryCount; i++) { try { return operation.get(); - } catch (RuntimeException e) { + } catch (CloudOperationException e) { handle(e); long waitMillis = computeWaitMillis(e, i); sleeper.accept(waitMillis); @@ -125,7 +125,7 @@ protected void handle(CloudOperationException e) { e); } - private long computeWaitMillis(RuntimeException e, int attemptIndex) { + private long computeWaitMillis(RuntimeException e, long attemptIndex) { if (e instanceof CloudOperationException) { CloudOperationException cloudException = (CloudOperationException) e; if (cloudException.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) { From bd58831dea417883ca83f5cc8ab7eb6e8c931a8d Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Thu, 16 Jul 2026 11:04:59 +0300 Subject: [PATCH 6/9] Refactor --- .../util/ResilientCloudOperationExecutor.java | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java index 6550076c1b..cdc0b04294 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java @@ -44,8 +44,8 @@ public class ResilientCloudOperationExecutor extends ResilientOperationExecutor private Set additionalStatusesToIgnore = Collections.emptySet(); private LongConsumer sleeper = MiscUtil::sleep; private LongSupplier randomDelaySupplier = () -> ThreadLocalRandom.current() - .nextLong(RANDOM_RETRY_MIN_WAIT_IN_MILLIS, - RANDOM_RETRY_MAX_WAIT_IN_MILLIS + 1); + .nextLong(RANDOM_RETRY_MIN_WAIT_IN_MILLIS, + RANDOM_RETRY_MAX_WAIT_IN_MILLIS + 1); @Override public ResilientCloudOperationExecutor withRetryCount(long retryCount) { @@ -126,19 +126,8 @@ protected void handle(CloudOperationException e) { } private long computeWaitMillis(RuntimeException e, long attemptIndex) { - if (e instanceof CloudOperationException) { - CloudOperationException cloudException = (CloudOperationException) e; - if (cloudException.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) { - Long retryAfterSeconds = cloudException.getRetryAfterSeconds(); - if (retryAfterSeconds != null) { - long cappedSeconds = Math.max(1L, Math.min(retryAfterSeconds, RATE_LIMIT_RETRY_AFTER_CAP_IN_SECONDS)); - long waitMillis = cappedSeconds * 1000L; - LOGGER.info(Messages.RATE_LIMITED_BY_CC_WAITING_S, retryAfterSeconds, cappedSeconds); - return waitMillis; - } - LOGGER.info(Messages.RATE_LIMITED_BY_CC_NO_HEADER_WAITING_S, RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS); - return RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS; - } + if (isRateLimitException(e)) { + return computeRateLimitWaitMillis((CloudOperationException) e); } if (attemptIndex >= 2) { long waitMillis = randomDelaySupplier.getAsLong(); @@ -148,6 +137,22 @@ private long computeWaitMillis(RuntimeException e, long attemptIndex) { return waitTimeBetweenRetriesInMillis; } + private boolean isRateLimitException(RuntimeException e) { + return e instanceof CloudOperationException cloudException + && cloudException.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS; + } + + private long computeRateLimitWaitMillis(CloudOperationException e) { + Long retryAfterSeconds = e.getRetryAfterSeconds(); + if (retryAfterSeconds == null) { + LOGGER.info(Messages.RATE_LIMITED_BY_CC_NO_HEADER_WAITING_S, RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS); + return RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS; + } + long cappedSeconds = Math.max(1L, Math.min(retryAfterSeconds, RATE_LIMIT_RETRY_AFTER_CAP_IN_SECONDS)); + LOGGER.info(Messages.RATE_LIMITED_BY_CC_WAITING_S, retryAfterSeconds, cappedSeconds); + return cappedSeconds * 1000L; + } + private boolean shouldRetry(CloudOperationException e) { return getStatusesToIgnore().contains(e.getStatusCode()); } From 92094b71e0e302e0d12c9cfad52a100ebc3d6bed Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Fri, 17 Jul 2026 14:12:44 +0300 Subject: [PATCH 7/9] Fix comments --- .../multiapps/controller/Messages.java | 2 +- .../CloudControllerResponseErrorHandler.java | 15 +-- .../util/ResilientCloudOperationExecutor.java | 6 +- ...oudControllerResponseErrorHandlerTest.java | 104 +++++++----------- .../ResilientCloudOperationExecutorTest.java | 57 +++------- 5 files changed, 62 insertions(+), 122 deletions(-) diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java index ec991ba330..85564688f4 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java @@ -5,7 +5,7 @@ public class Messages { // INFO messages public static final String WAITING_MS_BEFORE_RETRYING_WITH_TIMEOUT_OF_MS = "Waiting: {} ms before retrying with timeout of: {} ms"; public static final String RATE_LIMITED_BY_CC_WAITING_S = "CC returned 429 with Retry-After: {} s. Waiting {} s (capped) before retrying."; - public static final String RATE_LIMITED_BY_CC_NO_HEADER_WAITING_S = "CC returned 429 without Retry-After header. Waiting {} ms before retrying."; + public static final String RATE_LIMITED_BY_CC_NO_HEADER_WAITING_MS = "CC returned 429 without Retry-After header. Waiting {} ms before retrying."; public static final String RANDOM_WAIT_BEFORE_RETRY_S = "Waiting {} ms (randomized) before retrying failed CC operation."; } diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java index 27b806332f..97655b69a1 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java @@ -1,18 +1,19 @@ package org.cloudfoundry.multiapps.controller.client.facade.rest; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + import com.fasterxml.jackson.databind.ObjectMapper; import org.cloudfoundry.multiapps.controller.client.facade.CloudOperationException; import org.cloudfoundry.multiapps.controller.client.facade.util.CloudUtil; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.client.ClientHttpResponse; import org.springframework.web.client.DefaultResponseErrorHandler; import org.springframework.web.client.RestClientException; -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - public class CloudControllerResponseErrorHandler extends DefaultResponseErrorHandler { private static CloudOperationException getException(ClientHttpResponse response) throws IOException { @@ -40,14 +41,14 @@ private static Long extractRetryAfterSeconds(ClientHttpResponse response, HttpSt return null; } String headerValue = response.getHeaders() - .getFirst(org.springframework.http.HttpHeaders.RETRY_AFTER); + .getFirst(HttpHeaders.RETRY_AFTER); if (headerValue == null) { return null; } try { long parsed = Long.parseLong(headerValue); return parsed > 0 ? parsed : null; - } catch (NumberFormatException e) { + } catch (NumberFormatException _) { return null; } } diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java index cdc0b04294..4dad683428 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java @@ -77,7 +77,7 @@ public T execute(Supplier operation) { for (long i = 1; i < retryCount; i++) { try { return operation.get(); - } catch (CloudOperationException e) { + } catch (RuntimeException e) { handle(e); long waitMillis = computeWaitMillis(e, i); sleeper.accept(waitMillis); @@ -145,10 +145,10 @@ private boolean isRateLimitException(RuntimeException e) { private long computeRateLimitWaitMillis(CloudOperationException e) { Long retryAfterSeconds = e.getRetryAfterSeconds(); if (retryAfterSeconds == null) { - LOGGER.info(Messages.RATE_LIMITED_BY_CC_NO_HEADER_WAITING_S, RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS); + LOGGER.info(Messages.RATE_LIMITED_BY_CC_NO_HEADER_WAITING_MS, RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS); return RATE_LIMIT_FALLBACK_WAIT_IN_MILLIS; } - long cappedSeconds = Math.max(1L, Math.min(retryAfterSeconds, RATE_LIMIT_RETRY_AFTER_CAP_IN_SECONDS)); + long cappedSeconds = Math.clamp(retryAfterSeconds, 1L, RATE_LIMIT_RETRY_AFTER_CAP_IN_SECONDS); LOGGER.info(Messages.RATE_LIMITED_BY_CC_WAITING_S, retryAfterSeconds, cappedSeconds); return cappedSeconds * 1000L; } diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java index fd0eec94fb..fa0bd78109 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java @@ -1,28 +1,26 @@ package org.cloudfoundry.multiapps.controller.client.facade.rest; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.fail; - import java.io.ByteArrayInputStream; -import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import org.cloudfoundry.multiapps.controller.client.facade.CloudOperationException; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.client.ClientHttpResponse; import org.springframework.web.client.RestClientException; -import org.cloudfoundry.multiapps.controller.client.facade.CloudOperationException; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; class CloudControllerResponseErrorHandlerTest { private final CloudControllerResponseErrorHandler handler = new CloudControllerResponseErrorHandler(); @Test - void testWithV2Error() throws IOException { + void testWithV2Error() { HttpStatus statusCode = HttpStatus.UNPROCESSABLE_ENTITY; ClientHttpResponseMock response = new ClientHttpResponseMock(statusCode, getClass().getResourceAsStream("v2-error.json")); CloudOperationException expectedException = new CloudOperationException(statusCode, @@ -32,7 +30,7 @@ void testWithV2Error() throws IOException { } @Test - void testWithV3Error() throws IOException { + void testWithV3Error() { HttpStatus statusCode = HttpStatus.BAD_REQUEST; ClientHttpResponseMock response = new ClientHttpResponseMock(statusCode, getClass().getResourceAsStream("v3-error.json")); CloudOperationException expectedException = new CloudOperationException(statusCode, @@ -42,7 +40,7 @@ void testWithV3Error() throws IOException { } @Test - void testWithInvalidError() throws IOException { + void testWithInvalidError() { HttpStatus statusCode = HttpStatus.BAD_REQUEST; ClientHttpResponseMock response = new ClientHttpResponseMock(statusCode, toInputStream("blabla")); CloudOperationException expectedException = new CloudOperationException(statusCode, statusCode.getReasonPhrase()); @@ -50,7 +48,7 @@ void testWithInvalidError() throws IOException { } @Test - void testWithEmptyResponse() throws IOException { + void testWithEmptyResponse() { HttpStatus statusCode = HttpStatus.BAD_REQUEST; ClientHttpResponseMock response = new ClientHttpResponseMock(statusCode, toInputStream("{ }")); CloudOperationException expectedException = new CloudOperationException(statusCode, statusCode.getReasonPhrase()); @@ -58,94 +56,66 @@ void testWithEmptyResponse() throws IOException { } @Test - void testWith429AndRetryAfterHeader() throws IOException { + void testWith429AndRetryAfterHeader() { HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.RETRY_AFTER, "60"); ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{}"), headers); - try { - handler.handleError(response); - fail("Expected an exception"); - } catch (CloudOperationException e) { - assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); - assertEquals(60L, e.getRetryAfterSeconds()); - } + CloudOperationException e = assertThrows(CloudOperationException.class, () -> handler.handleError(response)); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); + assertEquals(60L, e.getRetryAfterSeconds()); } @Test - void testWith429WithoutRetryAfterHeader() throws IOException { + void testWith429WithoutRetryAfterHeader() { ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{}")); - try { - handler.handleError(response); - fail("Expected an exception"); - } catch (CloudOperationException e) { - assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); - assertNull(e.getRetryAfterSeconds()); - } + CloudOperationException e = assertThrows(CloudOperationException.class, () -> handler.handleError(response)); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); + assertNull(e.getRetryAfterSeconds()); } @Test - void testWith429AndNonNumericRetryAfterHeader() throws IOException { + void testWith429AndNonNumericRetryAfterHeader() { HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.RETRY_AFTER, "not-a-number"); ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{}"), headers); - try { - handler.handleError(response); - fail("Expected an exception"); - } catch (CloudOperationException e) { - assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); - assertNull(e.getRetryAfterSeconds()); - } + CloudOperationException e = assertThrows(CloudOperationException.class, () -> handler.handleError(response)); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); + assertNull(e.getRetryAfterSeconds()); } @Test - void testNon429DoesNotSetRetryAfterSeconds() throws IOException { + void testNon429DoesNotSetRetryAfterSeconds() { ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.INTERNAL_SERVER_ERROR, toInputStream("{}")); - try { - handler.handleError(response); - fail("Expected an exception"); - } catch (CloudOperationException e) { - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, e.getStatusCode()); - assertNull(e.getRetryAfterSeconds()); - } + CloudOperationException e = assertThrows(CloudOperationException.class, () -> handler.handleError(response)); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, e.getStatusCode()); + assertNull(e.getRetryAfterSeconds()); } @Test - void testWith429RetainsRetryAfterAndDescriptionWhenBodyPresent() throws IOException { + void testWith429RetainsRetryAfterAndDescriptionWhenBodyPresent() { HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.RETRY_AFTER, "30"); ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{\"description\":\"rate limit exceeded\"}"), headers); - try { - handler.handleError(response); - fail("Expected an exception"); - } catch (CloudOperationException e) { - assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); - assertEquals(30L, e.getRetryAfterSeconds()); - assertEquals("rate limit exceeded", e.getDescription()); - } + CloudOperationException e = assertThrows(CloudOperationException.class, () -> handler.handleError(response)); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, e.getStatusCode()); + assertEquals(30L, e.getRetryAfterSeconds()); + assertEquals("rate limit exceeded", e.getDescription()); } - private void testWithError(ClientHttpResponseMock response, CloudOperationException expectedException) throws IOException { - try { - handler.handleError(response); - fail("Expected an exception"); - } catch (CloudOperationException exception) { - assertEquals(expectedException.getStatusCode(), exception.getStatusCode()); - assertEquals(expectedException.getStatusText(), exception.getStatusText()); - assertEquals(expectedException.getDescription(), exception.getDescription()); - } + private void testWithError(ClientHttpResponseMock response, CloudOperationException expectedException) { + CloudOperationException exception = assertThrows(CloudOperationException.class, () -> handler.handleError(response)); + assertEquals(expectedException.getStatusCode(), exception.getStatusCode()); + assertEquals(expectedException.getStatusText(), exception.getStatusText()); + assertEquals(expectedException.getDescription(), exception.getDescription()); } @Test - void testWithNonClientOrServerError() throws IOException { + void testWithNonClientOrServerError() { HttpStatus statusCode = HttpStatus.PERMANENT_REDIRECT; ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.PERMANENT_REDIRECT, null); - try { - handler.handleError(response); - fail("Expected an exception"); - } catch (RestClientException exception) { - assertEquals("Unknown status code [" + statusCode + "]", exception.getMessage()); - } + RestClientException exception = assertThrows(RestClientException.class, () -> handler.handleError(response)); + assertEquals("Unknown status code [" + statusCode + "]", exception.getMessage()); } private InputStream toInputStream(String string) { diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java index 235e4f5443..4719f04173 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java @@ -4,11 +4,15 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; +import java.util.stream.Stream; import org.cloudfoundry.multiapps.controller.client.facade.CloudOperationException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.http.HttpStatus; class ResilientCloudOperationExecutorTest { @@ -76,50 +80,15 @@ void testFluentBuildersReturnSameTypeForChaining() { Assertions.assertNotNull(chained); } - @Test - void testRetryAfterHeaderIsHonouredOnRateLimit() { - List sleepCalls = new ArrayList<>(); - executor = new ResilientCloudOperationExecutor().withRetryCount(3) - .withSleeper(sleepCalls::add); - AtomicInteger attempts = new AtomicInteger(); - Supplier operation = () -> { - if (attempts.incrementAndGet() == 1) { - throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, - HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), - null, null, 2L); - } - return "ok"; - }; - - executor.execute(operation); - - Assertions.assertEquals(1, sleepCalls.size()); - Assertions.assertEquals(2_000L, sleepCalls.get(0)); + static Stream retryAfterHeaderCappingCases() { + return Stream.of(Arguments.of("below cap", 2L, 2_000L), + Arguments.of("above cap", 300L, 120_000L), + Arguments.of("at cap", 120L, 120_000L)); } - @Test - void testRetryAfterHeaderCappedAtMaximum() { - List sleepCalls = new ArrayList<>(); - executor = new ResilientCloudOperationExecutor().withRetryCount(3) - .withSleeper(sleepCalls::add); - AtomicInteger attempts = new AtomicInteger(); - Supplier operation = () -> { - if (attempts.incrementAndGet() == 1) { - throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, - HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), - null, null, 300L); - } - return "ok"; - }; - - executor.execute(operation); - - Assertions.assertEquals(1, sleepCalls.size()); - Assertions.assertEquals(120_000L, sleepCalls.get(0)); - } - - @Test - void testRetryAfterHeaderAtCapIsNotReduced() { + @ParameterizedTest(name = "{0}: retryAfter={1}s → sleep={2}ms") + @MethodSource("retryAfterHeaderCappingCases") + void testRetryAfterHeaderCapping(String description, long retryAfterSeconds, long expectedSleepMillis) { List sleepCalls = new ArrayList<>(); executor = new ResilientCloudOperationExecutor().withRetryCount(3) .withSleeper(sleepCalls::add); @@ -128,7 +97,7 @@ void testRetryAfterHeaderAtCapIsNotReduced() { if (attempts.incrementAndGet() == 1) { throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), - null, null, 120L); + null, null, retryAfterSeconds); } return "ok"; }; @@ -136,7 +105,7 @@ void testRetryAfterHeaderAtCapIsNotReduced() { executor.execute(operation); Assertions.assertEquals(1, sleepCalls.size()); - Assertions.assertEquals(120_000L, sleepCalls.get(0)); + Assertions.assertEquals(expectedSleepMillis, sleepCalls.get(0)); } @Test From dc32f28840ee7ebf61aec97a94ab4bd0596ae9b5 Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Fri, 17 Jul 2026 15:13:14 +0300 Subject: [PATCH 8/9] Fix comment from Veli --- .../ResilientCloudOperationExecutorTest.java | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java index 4719f04173..fd87bbcbf0 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutorTest.java @@ -18,16 +18,20 @@ class ResilientCloudOperationExecutorTest { private ResilientCloudOperationExecutor executor; + private List sleepCalls; + private AtomicInteger attempts; @BeforeEach void setUp() { + sleepCalls = new ArrayList<>(); + attempts = new AtomicInteger(); executor = new ResilientCloudOperationExecutor().withRetryCount(3) - .withWaitTimeBetweenRetriesInMillis(0); + .withWaitTimeBetweenRetriesInMillis(0) + .withSleeper(sleepCalls::add); } @Test void testRetriesOnDefaultIgnoredStatuses() { - AtomicInteger attempts = new AtomicInteger(); Supplier operation = () -> { if (attempts.incrementAndGet() < 2) { throw new CloudOperationException(HttpStatus.BAD_GATEWAY); @@ -43,7 +47,6 @@ void testRetriesOnDefaultIgnoredStatuses() { @Test void testThrowsImmediatelyOnNonIgnoredStatus() { - AtomicInteger attempts = new AtomicInteger(); Supplier operation = () -> { attempts.incrementAndGet(); throw new CloudOperationException(HttpStatus.NOT_FOUND); @@ -56,7 +59,6 @@ void testThrowsImmediatelyOnNonIgnoredStatus() { @Test void testWithStatusesToIgnoreAddsAdditionalRetryableStatuses() { - AtomicInteger attempts = new AtomicInteger(); Supplier operation = () -> { if (attempts.incrementAndGet() < 2) { throw new CloudOperationException(HttpStatus.NOT_FOUND); @@ -89,10 +91,6 @@ static Stream retryAfterHeaderCappingCases() { @ParameterizedTest(name = "{0}: retryAfter={1}s → sleep={2}ms") @MethodSource("retryAfterHeaderCappingCases") void testRetryAfterHeaderCapping(String description, long retryAfterSeconds, long expectedSleepMillis) { - List sleepCalls = new ArrayList<>(); - executor = new ResilientCloudOperationExecutor().withRetryCount(3) - .withSleeper(sleepCalls::add); - AtomicInteger attempts = new AtomicInteger(); Supplier operation = () -> { if (attempts.incrementAndGet() == 1) { throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, @@ -110,10 +108,6 @@ void testRetryAfterHeaderCapping(String description, long retryAfterSeconds, lon @Test void testRateLimitFallbackWhenRetryAfterAbsent() { - List sleepCalls = new ArrayList<>(); - executor = new ResilientCloudOperationExecutor().withRetryCount(3) - .withSleeper(sleepCalls::add); - AtomicInteger attempts = new AtomicInteger(); Supplier operation = () -> { if (attempts.incrementAndGet() == 1) { throw new CloudOperationException(HttpStatus.TOO_MANY_REQUESTS, @@ -131,11 +125,9 @@ void testRateLimitFallbackWhenRetryAfterAbsent() { @Test void testNon429UsesExistingFixedDelay() { - List sleepCalls = new ArrayList<>(); executor = new ResilientCloudOperationExecutor().withRetryCount(3) .withWaitTimeBetweenRetriesInMillis(5_000L) .withSleeper(sleepCalls::add); - AtomicInteger attempts = new AtomicInteger(); Supplier operation = () -> { if (attempts.incrementAndGet() == 1) { throw new CloudOperationException(HttpStatus.BAD_GATEWAY); @@ -152,12 +144,10 @@ void testNon429UsesExistingFixedDelay() { @Test void testRandomDelayAppliedForNon429AfterFirstRetry() { long deterministicDelay = 45_000L; - List sleepCalls = new ArrayList<>(); executor = new ResilientCloudOperationExecutor().withRetryCount(4) .withWaitTimeBetweenRetriesInMillis(0) .withSleeper(sleepCalls::add) .withRandomDelaySupplier(() -> deterministicDelay); - AtomicInteger attempts = new AtomicInteger(); Supplier operation = () -> { if (attempts.incrementAndGet() < 3) { throw new CloudOperationException(HttpStatus.INTERNAL_SERVER_ERROR); @@ -174,12 +164,10 @@ void testRandomDelayAppliedForNon429AfterFirstRetry() { @Test void testRetryableOperationThatNeverSucceedsIsEventuallyRethrown() { - List sleepCalls = new ArrayList<>(); executor = new ResilientCloudOperationExecutor().withRetryCount(3) .withWaitTimeBetweenRetriesInMillis(0) .withSleeper(sleepCalls::add) .withRandomDelaySupplier(() -> 0L); - AtomicInteger attempts = new AtomicInteger(); Supplier operation = () -> { attempts.incrementAndGet(); throw new CloudOperationException(HttpStatus.BAD_GATEWAY); @@ -192,11 +180,9 @@ void testRetryableOperationThatNeverSucceedsIsEventuallyRethrown() { @Test void testRunnableOverloadIsRetriedThroughOverriddenExecute() { - List sleepCalls = new ArrayList<>(); executor = new ResilientCloudOperationExecutor().withRetryCount(3) .withWaitTimeBetweenRetriesInMillis(7_000L) .withSleeper(sleepCalls::add); - AtomicInteger attempts = new AtomicInteger(); Runnable operation = () -> { if (attempts.incrementAndGet() < 2) { throw new CloudOperationException(HttpStatus.SERVICE_UNAVAILABLE); From 0ce1a86c341c7d2a4a5926bab9c053c012fd6337 Mon Sep 17 00:00:00 2001 From: Yavor16 Date: Mon, 20 Jul 2026 12:15:57 +0300 Subject: [PATCH 9/9] Fix comments from Veli --- .../multiapps/controller/Messages.java | 2 +- .../CloudControllerResponseErrorHandler.java | 2 +- .../util/ResilientCloudOperationExecutor.java | 2 +- ...loudControllerResponseErrorHandlerTest.java | 18 ++++++++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java index 85564688f4..af0d03d5f0 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/Messages.java @@ -6,6 +6,6 @@ public class Messages { public static final String WAITING_MS_BEFORE_RETRYING_WITH_TIMEOUT_OF_MS = "Waiting: {} ms before retrying with timeout of: {} ms"; public static final String RATE_LIMITED_BY_CC_WAITING_S = "CC returned 429 with Retry-After: {} s. Waiting {} s (capped) before retrying."; public static final String RATE_LIMITED_BY_CC_NO_HEADER_WAITING_MS = "CC returned 429 without Retry-After header. Waiting {} ms before retrying."; - public static final String RANDOM_WAIT_BEFORE_RETRY_S = "Waiting {} ms (randomized) before retrying failed CC operation."; + public static final String RANDOM_WAIT_BEFORE_RETRY_MS = "Waiting {} ms (randomized) before retrying failed CC operation."; } diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java index 97655b69a1..8b439225f5 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandler.java @@ -47,7 +47,7 @@ private static Long extractRetryAfterSeconds(ClientHttpResponse response, HttpSt } try { long parsed = Long.parseLong(headerValue); - return parsed > 0 ? parsed : null; + return parsed >= 0 ? parsed : null; } catch (NumberFormatException _) { return null; } diff --git a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java index 4dad683428..fcb9e7d9e3 100644 --- a/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java +++ b/multiapps-controller-client/src/main/java/org/cloudfoundry/multiapps/controller/client/util/ResilientCloudOperationExecutor.java @@ -131,7 +131,7 @@ private long computeWaitMillis(RuntimeException e, long attemptIndex) { } if (attemptIndex >= 2) { long waitMillis = randomDelaySupplier.getAsLong(); - LOGGER.info(Messages.RANDOM_WAIT_BEFORE_RETRY_S, waitMillis); + LOGGER.info(Messages.RANDOM_WAIT_BEFORE_RETRY_MS, waitMillis); return waitMillis; } return waitTimeBetweenRetriesInMillis; diff --git a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java index fa0bd78109..ddf3219cf4 100644 --- a/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java +++ b/multiapps-controller-client/src/test/java/org/cloudfoundry/multiapps/controller/client/facade/rest/CloudControllerResponseErrorHandlerTest.java @@ -73,6 +73,24 @@ void testWith429WithoutRetryAfterHeader() { assertNull(e.getRetryAfterSeconds()); } + @Test + void testWith429AndZeroRetryAfterHeaderIsAccepted() { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.RETRY_AFTER, "0"); + ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{}"), headers); + CloudOperationException e = assertThrows(CloudOperationException.class, () -> handler.handleError(response)); + assertEquals(0L, e.getRetryAfterSeconds()); + } + + @Test + void testWith429AndNegativeRetryAfterHeaderIsIgnored() { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.RETRY_AFTER, "-1"); + ClientHttpResponseMock response = new ClientHttpResponseMock(HttpStatus.TOO_MANY_REQUESTS, toInputStream("{}"), headers); + CloudOperationException e = assertThrows(CloudOperationException.class, () -> handler.handleError(response)); + assertNull(e.getRetryAfterSeconds()); + } + @Test void testWith429AndNonNumericRetryAfterHeader() { HttpHeaders headers = new HttpHeaders();