From 386c23c7d5e279773c27535d3f931359b6cd4a9e Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Thu, 27 Aug 2026 12:48:18 +0300 Subject: [PATCH 1/2] fix: keep runs with dropped batches open and honour Retry-After MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A batch that permanently failed to upload was counted and logged, but the run was still marked complete, so a report over partial data looked trustworthy and CI stayed green over missing results — the qase-python#504 symptom. completeTestRun now skips completion (and the public report link) when any batch was dropped, reporting how many results were lost, and the dropped-batch message is logged at ERROR since it means lost data. RetryHelper now honours the Retry-After response header. Qase answers HTTP 429 with Retry-After at roughly 60 seconds, while the computed backoff ladder (1s + 3s + 9s) was exhausted long before the rate limit cleared, so retries did not survive rate limiting in large parallel CI runs. A numeric value replaces the computed delay, capped at 120 seconds; an HTTP-date value falls back to the computed backoff rather than guessing at clock skew. computeUploadTimeout reserves the worst-case retry budget per submitted batch so awaitTermination cannot expire while a thread is correctly waiting out a rate limit. --- changelog.md | 6 + .../commons/reporters/TestopsReporter.java | 37 ++++- .../io/qase/commons/utils/RetryHelper.java | 104 +++++++++++- .../TestopsReporterFailedBatchTest.java | 135 ++++++++++++++++ .../reporters/UploadSummaryLogTest.java | 30 ++-- .../utils/RetryHelperRetryAfterTest.java | 148 ++++++++++++++++++ 6 files changed, 432 insertions(+), 28 deletions(-) create mode 100644 qase-java-commons/src/test/java/io/qase/commons/reporters/TestopsReporterFailedBatchTest.java create mode 100644 qase-java-commons/src/test/java/io/qase/commons/utils/RetryHelperRetryAfterTest.java diff --git a/changelog.md b/changelog.md index 08e1e332..63d3fae6 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,12 @@ - Updated API clients to the latest specification +## Bug fixes + +- A test run whose result batches failed to upload is no longer marked complete. Previously a permanently failed batch was counted and logged, but `completeTestRun` was still called, so a run over partial data looked trustworthy and CI stayed green over missing results. The run is now left open — a visible signal that results are missing — and the number of lost results, not just lost batches, is reported at `ERROR` level. Enabling the public report link is skipped for the same reason. +- The dropped-batch message (`Batch upload failed, N results dropped`) was raised from `WARN` to `ERROR`, since it means lost data. +- `RetryHelper` now honours the `Retry-After` response header. Qase answers HTTP 429 with `Retry-After` at roughly 60 seconds, while the computed backoff ladder (1s + 3s + 9s) was exhausted long before the rate limit cleared — so retries did not survive the most common real-world trigger, rate limiting in large parallel CI runs. A numeric `Retry-After` is now used in place of the computed delay, capped at 120 seconds; an HTTP-date value falls back to the computed backoff rather than guessing at clock skew. The dynamic upload timeout reserves the worst-case retry budget per submitted batch so `awaitTermination` cannot expire while a thread is correctly waiting out a rate limit. + # qase-java 4.1.60 ## Bug fixes diff --git a/qase-java-commons/src/main/java/io/qase/commons/reporters/TestopsReporter.java b/qase-java-commons/src/main/java/io/qase/commons/reporters/TestopsReporter.java index 5f26c380..dd533895 100644 --- a/qase-java-commons/src/main/java/io/qase/commons/reporters/TestopsReporter.java +++ b/qase-java-commons/src/main/java/io/qase/commons/reporters/TestopsReporter.java @@ -6,6 +6,7 @@ import io.qase.commons.logger.Logger; import io.qase.commons.models.domain.TestResult; import io.qase.commons.models.domain.TestResultStatus; +import io.qase.commons.utils.RetryHelper; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; @@ -34,10 +35,12 @@ public class TestopsReporter implements InternalReporter { private final AtomicLong statTotalBytes = new AtomicLong(0); private final AtomicLong statTotalUploadTimeMs = new AtomicLong(0); private final AtomicLong statFailedBatches = new AtomicLong(0); + private final AtomicLong statFailedResults = new AtomicLong(0); // Submitted (pending + in-flight) counters for dynamic timeout computation private final AtomicLong statSubmittedBytes = new AtomicLong(0); private final AtomicLong statSubmittedAttachments = new AtomicLong(0); + private final AtomicLong statSubmittedBatches = new AtomicLong(0); // Dynamic timeout constants static final long MINIMUM_UPLOAD_SPEED_BPS = 500L * 1024; // 500 KB/s conservative estimate @@ -95,6 +98,17 @@ public void completeTestRun() throws QaseException { statTotalUploadTimeMs.get(), statFailedBatches.get()); + // A run whose batches were dropped must not be marked complete: a completed + // run over partial data looks trustworthy and is not, while an open run is a + // visible signal that results are missing. + long failedBatches = statFailedBatches.get(); + if (failedBatches > 0) { + logger.error("Test run %d: NOT completing the run - %d result(s) in %d batch(es) failed to upload " + + "and are missing from the run. The run is left open so the missing data stays visible.", + this.testRunId, statFailedResults.get(), failedBatches); + return; + } + if (!this.config.run.complete) { logger.info("Test run %d: skipping completion (complete=false)", this.testRunId); return; @@ -157,19 +171,30 @@ public synchronized void uploadResults() throws QaseException { /** * Computes the upload timeout based on submitted data volume and configured minimum. - * Formula: max(configured, BASE + submittedBytes / MIN_SPEED + attachments * OVERHEAD), capped at MAX. + * Formula: max(configured, BASE + submittedBytes / MIN_SPEED + attachments * OVERHEAD + * + batches * retryBudget), capped at MAX. + * + * The retry budget matters because a batch answering HTTP 429 sleeps for the + * server's Retry-After (up to two minutes) inside {@code uploadBatch}. Without + * reserving that time, awaitTermination would expire while a thread is correctly + * waiting out a rate limit, and the results would be reported as dropped. + * * Package-private for unit testing. */ int computeUploadTimeout() { long bytes = statSubmittedBytes.get(); long attachments = statSubmittedAttachments.get(); + long batches = statSubmittedBatches.get(); + long retryBudgetSeconds = batches * RetryHelper.maxRetryDelaySeconds(); long dynamicSeconds = BASE_TIMEOUT_SECONDS + bytes / MINIMUM_UPLOAD_SPEED_BPS - + attachments * PER_ATTACHMENT_OVERHEAD_SECONDS; + + attachments * PER_ATTACHMENT_OVERHEAD_SECONDS + + retryBudgetSeconds; int computed = (int) Math.min(dynamicSeconds, MAX_TIMEOUT_SECONDS); int timeout = Math.max(config.batch.uploadTimeout, computed); - logger.debug("Dynamic upload timeout: %ds (configured=%ds, computed=%ds, submitted=%d bytes, %d attachments)", - timeout, config.batch.uploadTimeout, computed, bytes, attachments); + logger.debug("Dynamic upload timeout: %ds (configured=%ds, computed=%ds, submitted=%d bytes, " + + "%d attachments, %d batches, %ds retry budget)", + timeout, config.batch.uploadTimeout, computed, bytes, attachments, batches, retryBudgetSeconds); return timeout; } @@ -183,6 +208,7 @@ private void trackSubmittedBatch(List batch) { .sum(); statSubmittedBytes.addAndGet(bytes); statSubmittedAttachments.addAndGet(attachments); + statSubmittedBatches.incrementAndGet(); } private void uploadBatch(List batch) { @@ -207,7 +233,8 @@ private void uploadBatch(List batch) { statTotalUploadTimeMs.addAndGet(elapsedMs); } catch (QaseException e) { statFailedBatches.incrementAndGet(); - logger.warn("Batch upload failed, %d results dropped: %s", batch.size(), e.getMessage()); + statFailedResults.addAndGet(batch.size()); + logger.error("Batch upload failed, %d results dropped: %s", batch.size(), e.getMessage()); } } diff --git a/qase-java-commons/src/main/java/io/qase/commons/utils/RetryHelper.java b/qase-java-commons/src/main/java/io/qase/commons/utils/RetryHelper.java index b5b468ef..4b61efc9 100644 --- a/qase-java-commons/src/main/java/io/qase/commons/utils/RetryHelper.java +++ b/qase-java-commons/src/main/java/io/qase/commons/utils/RetryHelper.java @@ -2,6 +2,9 @@ import io.qase.commons.logger.Logger; +import java.util.List; +import java.util.Map; + public class RetryHelper { private static final Logger logger = Logger.getInstance(); @@ -9,6 +12,18 @@ public class RetryHelper { static final int BASE_DELAY_MS = 1000; static final int BACKOFF_MULTIPLIER = 3; + /** + * Header the API sends with HTTP 429 stating how long the rate limit lasts. + */ + private static final String RETRY_AFTER_HEADER = "Retry-After"; + + /** + * Upper bound on a honoured Retry-After. Qase answers 429 with roughly 60 + * seconds; the cap keeps an implausible value from parking an upload thread + * for the rest of the build. + */ + static final long MAX_RETRY_AFTER_MS = 120_000L; + @FunctionalInterface public interface ThrowingSupplier { T get() throws Exception; @@ -36,14 +51,21 @@ public static T retry(ThrowingSupplier action, String actionName) throws throw e; } - int delay = BASE_DELAY_MS * (int) Math.pow(BACKOFF_MULTIPLIER, attempt); + // A rate limit clears when the server says it clears, not when our + // backoff ladder happens to run out. + long retryAfterMs = extractRetryAfterMs(e); + long delay = retryAfterMs > 0 + ? retryAfterMs + : (long) BASE_DELAY_MS * (long) Math.pow(BACKOFF_MULTIPLIER, attempt); + String delaySource = retryAfterMs > 0 ? " (Retry-After)" : ""; + int httpCode = extractHttpCode(e); if (httpCode > 0) { - logger.warn("Retrying '%s' (attempt %d/%d) after %dms: HTTP %d - %s", - actionName, attempt + 1, MAX_RETRIES, delay, httpCode, e.getMessage()); + logger.warn("Retrying '%s' (attempt %d/%d) after %dms%s: HTTP %d - %s", + actionName, attempt + 1, MAX_RETRIES, delay, delaySource, httpCode, e.getMessage()); } else { - logger.warn("Retrying '%s' (attempt %d/%d) after %dms: %s", - actionName, attempt + 1, MAX_RETRIES, delay, e.getMessage()); + logger.warn("Retrying '%s' (attempt %d/%d) after %dms%s: %s", + actionName, attempt + 1, MAX_RETRIES, delay, delaySource, e.getMessage()); } try { @@ -65,11 +87,83 @@ public static void retry(ThrowingRunnable action, String actionName) throws Exce }, actionName); } + /** + * Worst-case total time a single retried action can spend sleeping, in seconds. + * Callers that wait on a retried action (the upload executor) need this so their + * own timeout does not expire while a thread is correctly waiting out a + * Retry-After. + * + * @return the sum of the longest possible delay for every retry attempt + */ + public static int maxRetryDelaySeconds() { + long totalMs = 0; + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + long computed = (long) BASE_DELAY_MS * (long) Math.pow(BACKOFF_MULTIPLIER, attempt); + totalMs += Math.max(MAX_RETRY_AFTER_MS, computed); + } + return (int) ((totalMs + 999) / 1000); + } + static boolean isRetryable(Exception e) { int code = extractHttpCode(e); return code == 0 || code == 408 || code == 429 || code >= 500; } + /** + * Reads the Retry-After header off an API exception. + * + * @return the delay in milliseconds, capped at {@link #MAX_RETRY_AFTER_MS}, + * or -1 when the header is absent or not a plain number of seconds + */ + static long extractRetryAfterMs(Exception e) { + Map> headers = extractResponseHeaders(e); + if (headers == null || headers.isEmpty()) { + return -1; + } + + for (Map.Entry> header : headers.entrySet()) { + String name = header.getKey(); + if (name == null || !RETRY_AFTER_HEADER.equalsIgnoreCase(name.trim())) { + continue; + } + List values = header.getValue(); + if (values == null || values.isEmpty() || values.get(0) == null) { + return -1; + } + return parseRetryAfterMs(values.get(0).trim()); + } + + return -1; + } + + private static long parseRetryAfterMs(String value) { + try { + long seconds = Long.parseLong(value); + if (seconds <= 0) { + return -1; + } + if (seconds > MAX_RETRY_AFTER_MS / 1000) { + return MAX_RETRY_AFTER_MS; + } + return seconds * 1000L; + } catch (NumberFormatException ex) { + // Retry-After may be an HTTP-date instead of a delay in seconds. + // Honouring it would mean guessing at clock skew between us and the + // server, so fall back to the computed backoff. + return -1; + } + } + + private static Map> extractResponseHeaders(Exception e) { + if (e instanceof io.qase.client.v1.ApiException) { + return ((io.qase.client.v1.ApiException) e).getResponseHeaders(); + } + if (e instanceof io.qase.client.v2.ApiException) { + return ((io.qase.client.v2.ApiException) e).getResponseHeaders(); + } + return null; + } + private static int extractHttpCode(Exception e) { if (e instanceof io.qase.client.v1.ApiException) { return ((io.qase.client.v1.ApiException) e).getCode(); diff --git a/qase-java-commons/src/test/java/io/qase/commons/reporters/TestopsReporterFailedBatchTest.java b/qase-java-commons/src/test/java/io/qase/commons/reporters/TestopsReporterFailedBatchTest.java new file mode 100644 index 00000000..22cb12ff --- /dev/null +++ b/qase-java-commons/src/test/java/io/qase/commons/reporters/TestopsReporterFailedBatchTest.java @@ -0,0 +1,135 @@ +package io.qase.commons.reporters; + +import io.qase.commons.QaseException; +import io.qase.commons.client.ApiClient; +import io.qase.commons.config.ConfigFactory; +import io.qase.commons.config.QaseConfig; +import io.qase.commons.models.domain.TestResult; +import io.qase.commons.models.domain.TestResultExecution; +import io.qase.commons.models.domain.TestResultStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A run whose result batches were dropped must NOT be marked complete. + * + * Regression guard for qase-python#504: a batch that failed to upload was lost + * silently and the run was still completed, so CI stayed green over missing + * data. An open run is a visible signal; a completed run over partial data is + * not. + */ +class TestopsReporterFailedBatchTest { + + private ApiClient clientMock; + private QaseConfig config; + + @BeforeEach + void setUp() throws QaseException { + clientMock = mock(ApiClient.class); + + config = ConfigFactory.loadConfig(); + config.testops.run.id = 0; + config.testops.run.complete = true; + // Large batch size so addResult() does not auto-trigger an upload + config.testops.batch.size = 200; + config.testops.showPublicReportLink = false; + + when(clientMock.createTestRun()).thenReturn(1L); + doNothing().when(clientMock).completeTestRun(anyLong()); + } + + private TestResult passedResult(String title) { + TestResult result = new TestResult(); + result.title = title; + result.execution = new TestResultExecution(); + result.execution.status = TestResultStatus.PASSED; + return result; + } + + @Test + void runIsNotCompletedWhenABatchFailedToUpload() throws Exception { + doThrow(new QaseException("upload permanently failed")) + .when(clientMock).uploadResults(anyLong(), anyList()); + + TestopsReporter reporter = new TestopsReporter(config.testops, clientMock); + reporter.startTestRun(); + reporter.addResult(passedResult("test one")); + reporter.completeTestRun(); + + verify(clientMock, times(1)).uploadResults(anyLong(), anyList()); + verify(clientMock, never()).completeTestRun(anyLong()); + } + + @Test + void publicReportIsNotEnabledWhenABatchFailedToUpload() throws Exception { + config.testops.showPublicReportLink = true; + doThrow(new QaseException("upload permanently failed")) + .when(clientMock).uploadResults(anyLong(), anyList()); + + TestopsReporter reporter = new TestopsReporter(config.testops, clientMock); + reporter.startTestRun(); + reporter.addResult(passedResult("test one")); + reporter.completeTestRun(); + + verify(clientMock, never()).completeTestRun(anyLong()); + verify(clientMock, never()).enablePublicReport(anyLong()); + } + + @Test + void runIsCompletedWhenEveryBatchUploadedSuccessfully() throws Exception { + doNothing().when(clientMock).uploadResults(anyLong(), anyList()); + + TestopsReporter reporter = new TestopsReporter(config.testops, clientMock); + reporter.startTestRun(); + reporter.addResult(passedResult("test one")); + reporter.addResult(passedResult("test two")); + reporter.completeTestRun(); + + verify(clientMock, times(1)).uploadResults(anyLong(), anyList()); + verify(clientMock, times(1)).completeTestRun(1L); + } + + @Test + void runWithNoResultsAtAllIsStillCompleted() throws Exception { + TestopsReporter reporter = new TestopsReporter(config.testops, clientMock); + reporter.startTestRun(); + reporter.completeTestRun(); + + verify(clientMock, never()).uploadResults(anyLong(), anyList()); + verify(clientMock, times(1)).completeTestRun(1L); + } + + /** + * The dropped-batch message means lost data, so it must be logged at ERROR, + * not WARN. + */ + @Test + void droppedBatchIsLoggedAtErrorLevel() throws Exception { + doThrow(new QaseException("upload permanently failed")) + .when(clientMock).uploadResults(anyLong(), anyList()); + + io.qase.commons.logger.Logger logger = io.qase.commons.logger.Logger.getInstance(); + + TestopsReporter reporter = new TestopsReporter(config.testops, clientMock); + reporter.startTestRun(); + reporter.addResult(passedResult("test one")); + + long errorsBefore = logger.getStatistics().get("ERROR"); + reporter.completeTestRun(); + long errorsAfter = logger.getStatistics().get("ERROR"); + + org.junit.jupiter.api.Assertions.assertTrue(errorsAfter - errorsBefore >= 2, + "Expected at least 2 ERROR logs (dropped batch + skipped completion), got " + + (errorsAfter - errorsBefore)); + } +} diff --git a/qase-java-commons/src/test/java/io/qase/commons/reporters/UploadSummaryLogTest.java b/qase-java-commons/src/test/java/io/qase/commons/reporters/UploadSummaryLogTest.java index 0fbffe92..574c6dc0 100644 --- a/qase-java-commons/src/test/java/io/qase/commons/reporters/UploadSummaryLogTest.java +++ b/qase-java-commons/src/test/java/io/qase/commons/reporters/UploadSummaryLogTest.java @@ -128,18 +128,12 @@ void summaryLogEmittedAtRunCompletion() throws Exception { * LOGS-04: When uploadResults throws QaseException, statFailedBatches increments * and the summary log is still emitted in completeTestRun(). * - * Known INFO logs from current code during completeTestRun() with 1 failed batch: + * INFO logs during completeTestRun() with 1 failed batch: * 1. "Uploading batch: 1 results, 1 attachments, 0.0 MB" - * 2. "Test run 1 completed" - * (Note: the WARN for the failed batch does not count as INFO) - * - * With LOGS-04 implemented: - * 1. "Uploading batch: ..." - * 2. "Upload summary: ..." <- NEW - * 3. "Test run 1 completed" - * - * Capture infoBefore right before completeTestRun(). We expect infoIncrement >= 3. - * This FAILS because infoIncrement == 2 (no summary log in current code). + * 2. "Upload summary: ..." + * (Note: the ERROR for the failed batch does not count as INFO, and + * "Test run 1 completed" is NOT emitted — a run with dropped batches is + * deliberately left open, see TestopsReporterFailedBatchTest.) */ @Test void summaryCountsFailedBatches() throws Exception { @@ -156,19 +150,19 @@ void summaryCountsFailedBatches() throws Exception { long infoBefore = logger.getStatistics().get("INFO"); // completeTestRun() flushes buffer, batch fails, summary should still fire - // Current code INFOs during this: "Uploading batch: ..." + "Test run 1 completed" = 2 - // LOGS-04 target: "Uploading batch: ..." + "Upload summary: ..." + "Test run 1 completed" = 3 reporter.completeTestRun(); long infoAfter = logger.getStatistics().get("INFO"); long infoIncrement = infoAfter - infoBefore; - assertTrue(infoIncrement >= 3, - "completeTestRun() with a failed batch must emit at least 3 INFO messages: " + - "'Uploading batch', 'Upload summary' (LOGS-04), and 'Test run completed'. " + + assertTrue(infoIncrement >= 2, + "completeTestRun() with a failed batch must emit at least 2 INFO messages: " + + "'Uploading batch' and 'Upload summary' (LOGS-04). " + "Got infoIncrement=" + infoIncrement + - " (infoBefore=" + infoBefore + ", infoAfter=" + infoAfter + "). " + - "This FAILS because the summary log does not exist yet."); + " (infoBefore=" + infoBefore + ", infoAfter=" + infoAfter + ")."); + + // The summary must be reported, but the run must not be closed over the loss. + verify(clientMock, never()).completeTestRun(anyLong()); } // ------------------------------------------------------------------------- diff --git a/qase-java-commons/src/test/java/io/qase/commons/utils/RetryHelperRetryAfterTest.java b/qase-java-commons/src/test/java/io/qase/commons/utils/RetryHelperRetryAfterTest.java new file mode 100644 index 00000000..9bf526a7 --- /dev/null +++ b/qase-java-commons/src/test/java/io/qase/commons/utils/RetryHelperRetryAfterTest.java @@ -0,0 +1,148 @@ +package io.qase.commons.utils; + +import io.qase.client.v1.ApiException; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Qase answers HTTP 429 with a Retry-After header of roughly 60 seconds. The + * computed backoff ladder (1s + 3s + 9s) is exhausted long before the limit + * clears, so the retry must honour the header instead. + */ +class RetryHelperRetryAfterTest { + + private static Map> headers(String name, String value) { + Map> headers = new HashMap<>(); + headers.put(name, Collections.singletonList(value)); + return headers; + } + + private static ApiException rateLimited(Map> headers) { + return new ApiException(429, "Too Many Requests", headers, "{}"); + } + + // ------------------------------------------------------------------------- + // Header parsing + // ------------------------------------------------------------------------- + + @Test + void numericRetryAfterIsParsedAsSeconds() { + assertEquals(60_000L, + RetryHelper.extractRetryAfterMs(rateLimited(headers("Retry-After", "60")))); + } + + @Test + void retryAfterHeaderNameIsCaseInsensitive() { + assertEquals(5_000L, + RetryHelper.extractRetryAfterMs(rateLimited(headers("retry-after", "5")))); + assertEquals(5_000L, + RetryHelper.extractRetryAfterMs(rateLimited(headers("RETRY-AFTER", "5")))); + } + + @Test + void surroundingWhitespaceIsTolerated() { + assertEquals(7_000L, + RetryHelper.extractRetryAfterMs(rateLimited(headers("Retry-After", " 7 ")))); + } + + @Test + void absentHeaderYieldsNoDelay() { + assertEquals(-1L, RetryHelper.extractRetryAfterMs(rateLimited(headers("X-Other", "1")))); + assertEquals(-1L, RetryHelper.extractRetryAfterMs(rateLimited(new HashMap<>()))); + assertEquals(-1L, RetryHelper.extractRetryAfterMs(new ApiException(429, "no headers"))); + assertEquals(-1L, RetryHelper.extractRetryAfterMs(new RuntimeException("not an ApiException"))); + } + + /** + * Retry-After may be an HTTP-date instead of a number. Parsing it would mean + * guessing at clock skew between us and the server, so we fall back to the + * computed backoff rather than trusting the difference. + */ + @Test + void httpDateRetryAfterFallsBackToComputedDelay() { + assertEquals(-1L, RetryHelper.extractRetryAfterMs( + rateLimited(headers("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT")))); + } + + @Test + void garbageAndNonPositiveValuesFallBackToComputedDelay() { + assertEquals(-1L, RetryHelper.extractRetryAfterMs(rateLimited(headers("Retry-After", "soon")))); + assertEquals(-1L, RetryHelper.extractRetryAfterMs(rateLimited(headers("Retry-After", "")))); + assertEquals(-1L, RetryHelper.extractRetryAfterMs(rateLimited(headers("Retry-After", "-5")))); + assertEquals(-1L, RetryHelper.extractRetryAfterMs(rateLimited(headers("Retry-After", "0")))); + } + + @Test + void v2ApiExceptionHeadersAreRead() { + assertEquals(30_000L, RetryHelper.extractRetryAfterMs( + new io.qase.client.v2.ApiException(429, "Too Many Requests", + headers("Retry-After", "30"), "{}"))); + } + + /** + * An implausibly large Retry-After must not park an upload thread forever. + */ + @Test + void absurdRetryAfterIsCappedAtTheMaximum() { + assertEquals(RetryHelper.MAX_RETRY_AFTER_MS, RetryHelper.extractRetryAfterMs( + rateLimited(headers("Retry-After", "86400")))); + } + + // ------------------------------------------------------------------------- + // Applied delay + // ------------------------------------------------------------------------- + + @Test + void retryWaitsTheRetryAfterValueWhenPresent() throws Exception { + AtomicInteger attempts = new AtomicInteger(0); + + long startNanos = System.nanoTime(); + String result = RetryHelper.retry(() -> { + if (attempts.incrementAndGet() == 1) { + throw rateLimited(headers("Retry-After", "3")); + } + return "ok"; + }, "rate limited action"); + long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; + + assertEquals("ok", result); + assertEquals(2, attempts.get()); + assertTrue(elapsedMs >= 3_000, + "Retry-After: 3 must be honoured over the 1000ms computed backoff, waited " + elapsedMs + "ms"); + } + + @Test + void retryUsesComputedBackoffWhenHeaderIsAbsent() throws Exception { + AtomicInteger attempts = new AtomicInteger(0); + + long startNanos = System.nanoTime(); + String result = RetryHelper.retry(() -> { + if (attempts.incrementAndGet() == 1) { + throw new ApiException(429, "Too Many Requests"); + } + return "ok"; + }, "rate limited action without header"); + long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; + + assertEquals("ok", result); + assertEquals(2, attempts.get()); + assertTrue(elapsedMs >= RetryHelper.BASE_DELAY_MS, + "computed backoff of " + RetryHelper.BASE_DELAY_MS + "ms must still apply, waited " + elapsedMs + "ms"); + assertTrue(elapsedMs < 3_000, + "without a header the wait must stay at the computed backoff, waited " + elapsedMs + "ms"); + } + + @Test + void worstCaseRetryDelayCoversEveryAttempt() { + assertTrue(RetryHelper.maxRetryDelaySeconds() >= RetryHelper.MAX_RETRIES * (RetryHelper.MAX_RETRY_AFTER_MS / 1000), + "the retry budget must cover a Retry-After wait on every attempt"); + } +} From ee7baf2783acf301517d91506a22e1f957f50ee9 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Thu, 27 Aug 2026 13:43:20 +0300 Subject: [PATCH 2/2] chore: bump version to 4.1.62 and update changelog 4.1.61 was already released (tag qase-java-v4.1.61), so the fixes move into a new 4.1.62 section instead of being appended to a shipped release. --- changelog.md | 8 +++++--- pom.xml | 2 +- qase-api-client/pom.xml | 2 +- qase-api-v2-client/pom.xml | 2 +- qase-cucumber-v3-reporter/pom.xml | 2 +- qase-cucumber-v4-reporter/pom.xml | 2 +- qase-cucumber-v5-reporter/pom.xml | 2 +- qase-cucumber-v6-reporter/pom.xml | 2 +- qase-cucumber-v7-reporter/pom.xml | 2 +- qase-java-commons/pom.xml | 2 +- qase-junit4-reporter/pom.xml | 2 +- qase-junit5-reporter/pom.xml | 2 +- qase-testng-reporter/pom.xml | 2 +- 13 files changed, 17 insertions(+), 15 deletions(-) diff --git a/changelog.md b/changelog.md index 63d3fae6..590049b1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,4 @@ -# qase-java 4.1.61 - -- Updated API clients to the latest specification +# qase-java 4.1.62 ## Bug fixes @@ -8,6 +6,10 @@ - The dropped-batch message (`Batch upload failed, N results dropped`) was raised from `WARN` to `ERROR`, since it means lost data. - `RetryHelper` now honours the `Retry-After` response header. Qase answers HTTP 429 with `Retry-After` at roughly 60 seconds, while the computed backoff ladder (1s + 3s + 9s) was exhausted long before the rate limit cleared — so retries did not survive the most common real-world trigger, rate limiting in large parallel CI runs. A numeric `Retry-After` is now used in place of the computed delay, capped at 120 seconds; an HTTP-date value falls back to the computed backoff rather than guessing at clock skew. The dynamic upload timeout reserves the worst-case retry budget per submitted batch so `awaitTermination` cannot expire while a thread is correctly waiting out a rate limit. +# qase-java 4.1.61 + +- Updated API clients to the latest specification + # qase-java 4.1.60 ## Bug fixes diff --git a/pom.xml b/pom.xml index 91223ef5..b6b1336a 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.qase qase-java pom - 4.1.61 + 4.1.62 qase-java-commons qase-api-client diff --git a/qase-api-client/pom.xml b/qase-api-client/pom.xml index b02bd962..e1f1c75d 100644 --- a/qase-api-client/pom.xml +++ b/qase-api-client/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0 diff --git a/qase-api-v2-client/pom.xml b/qase-api-v2-client/pom.xml index 9c514893..1e1a5e5e 100644 --- a/qase-api-v2-client/pom.xml +++ b/qase-api-v2-client/pom.xml @@ -6,7 +6,7 @@ io.qase qase-java - 4.1.61 + 4.1.62 qase-api-v2-client diff --git a/qase-cucumber-v3-reporter/pom.xml b/qase-cucumber-v3-reporter/pom.xml index 062c4c86..c4f077b7 100644 --- a/qase-cucumber-v3-reporter/pom.xml +++ b/qase-cucumber-v3-reporter/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0 diff --git a/qase-cucumber-v4-reporter/pom.xml b/qase-cucumber-v4-reporter/pom.xml index af5cd74d..09561e85 100644 --- a/qase-cucumber-v4-reporter/pom.xml +++ b/qase-cucumber-v4-reporter/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0 diff --git a/qase-cucumber-v5-reporter/pom.xml b/qase-cucumber-v5-reporter/pom.xml index d1282cb5..a9599a8d 100644 --- a/qase-cucumber-v5-reporter/pom.xml +++ b/qase-cucumber-v5-reporter/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0 diff --git a/qase-cucumber-v6-reporter/pom.xml b/qase-cucumber-v6-reporter/pom.xml index 0bbbc901..142bdfdb 100644 --- a/qase-cucumber-v6-reporter/pom.xml +++ b/qase-cucumber-v6-reporter/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0 diff --git a/qase-cucumber-v7-reporter/pom.xml b/qase-cucumber-v7-reporter/pom.xml index fd7fe2ce..777b48ef 100644 --- a/qase-cucumber-v7-reporter/pom.xml +++ b/qase-cucumber-v7-reporter/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0 diff --git a/qase-java-commons/pom.xml b/qase-java-commons/pom.xml index e78f5618..fef62a30 100644 --- a/qase-java-commons/pom.xml +++ b/qase-java-commons/pom.xml @@ -6,7 +6,7 @@ io.qase qase-java - 4.1.61 + 4.1.62 qase-java-commons diff --git a/qase-junit4-reporter/pom.xml b/qase-junit4-reporter/pom.xml index 67f09994..2ec5978d 100644 --- a/qase-junit4-reporter/pom.xml +++ b/qase-junit4-reporter/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0 diff --git a/qase-junit5-reporter/pom.xml b/qase-junit5-reporter/pom.xml index 0ef5e9bb..71de1ca0 100644 --- a/qase-junit5-reporter/pom.xml +++ b/qase-junit5-reporter/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0 diff --git a/qase-testng-reporter/pom.xml b/qase-testng-reporter/pom.xml index 5812268a..80691bdb 100644 --- a/qase-testng-reporter/pom.xml +++ b/qase-testng-reporter/pom.xml @@ -5,7 +5,7 @@ qase-java io.qase - 4.1.61 + 4.1.62 4.0.0