Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<groupId>io.qase</groupId>
<artifactId>qase-java</artifactId>
<packaging>pom</packaging>
<version>4.1.61</version>
<version>4.1.62</version>
<modules>
<module>qase-java-commons</module>
<module>qase-api-client</module>
Expand Down
2 changes: 1 addition & 1 deletion qase-api-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<artifactId>qase-java</artifactId>
<groupId>io.qase</groupId>
<version>4.1.61</version>
<version>4.1.62</version>
</parent>
<modelVersion>4.0.0</modelVersion>

Expand Down
2 changes: 1 addition & 1 deletion qase-api-v2-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<parent>
<groupId>io.qase</groupId>
<artifactId>qase-java</artifactId>
<version>4.1.61</version>
<version>4.1.62</version>
</parent>

<artifactId>qase-api-v2-client</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion qase-cucumber-v3-reporter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<artifactId>qase-java</artifactId>
<groupId>io.qase</groupId>
<version>4.1.61</version>
<version>4.1.62</version>
</parent>
<modelVersion>4.0.0</modelVersion>

Expand Down
2 changes: 1 addition & 1 deletion qase-cucumber-v4-reporter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<artifactId>qase-java</artifactId>
<groupId>io.qase</groupId>
<version>4.1.61</version>
<version>4.1.62</version>
</parent>
<modelVersion>4.0.0</modelVersion>

Expand Down
2 changes: 1 addition & 1 deletion qase-cucumber-v5-reporter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<artifactId>qase-java</artifactId>
<groupId>io.qase</groupId>
<version>4.1.61</version>
<version>4.1.62</version>
</parent>
<modelVersion>4.0.0</modelVersion>

Expand Down
2 changes: 1 addition & 1 deletion qase-cucumber-v6-reporter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<artifactId>qase-java</artifactId>
<groupId>io.qase</groupId>
<version>4.1.61</version>
<version>4.1.62</version>
</parent>
<modelVersion>4.0.0</modelVersion>

Expand Down
2 changes: 1 addition & 1 deletion qase-cucumber-v7-reporter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<artifactId>qase-java</artifactId>
<groupId>io.qase</groupId>
<version>4.1.61</version>
<version>4.1.62</version>
</parent>
<modelVersion>4.0.0</modelVersion>

Expand Down
2 changes: 1 addition & 1 deletion qase-java-commons/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<parent>
<groupId>io.qase</groupId>
<artifactId>qase-java</artifactId>
<version>4.1.61</version>
<version>4.1.62</version>
</parent>

<artifactId>qase-java-commons</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -183,6 +208,7 @@ private void trackSubmittedBatch(List<TestResult> batch) {
.sum();
statSubmittedBytes.addAndGet(bytes);
statSubmittedAttachments.addAndGet(attachments);
statSubmittedBatches.incrementAndGet();
}

private void uploadBatch(List<TestResult> batch) {
Expand All @@ -207,7 +233,8 @@ private void uploadBatch(List<TestResult> 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());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,28 @@

import io.qase.commons.logger.Logger;

import java.util.List;
import java.util.Map;

public class RetryHelper {
private static final Logger logger = Logger.getInstance();

static final int MAX_RETRIES = 3;
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> {
T get() throws Exception;
Expand Down Expand Up @@ -36,14 +51,21 @@ public static <T> T retry(ThrowingSupplier<T> 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 {
Expand All @@ -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<String, List<String>> headers = extractResponseHeaders(e);
if (headers == null || headers.isEmpty()) {
return -1;
}

for (Map.Entry<String, List<String>> header : headers.entrySet()) {
String name = header.getKey();
if (name == null || !RETRY_AFTER_HEADER.equalsIgnoreCase(name.trim())) {
continue;
}
List<String> 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<String, List<String>> 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();
Expand Down
Loading
Loading