diff --git a/changelog.md b/changelog.md
index 08e1e332..590049b1 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,11 @@
+# qase-java 4.1.62
+
+## 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.61
- Updated API clients to the latest specification
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-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");
+ }
+}
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