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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Fixed 🐛

- **A `202` on the execute-result poll no longer ends the poll with an empty result.** The API
answers `202` with an empty body when a job is known but its result is not readable yet, so a
successful response can legitimately carry no `state`. `BacktestWorkflow` dereferenced
`getState()` inside the retry predicate, and the resulting `NullPointerException` was swallowed
by the retry policy: the predicate simply did not match, the poll stopped, and the caller
received a `null` `ResultMap` for a backtest that had actually completed. The status is now read
through a null-safe accessor, so an absent state normalizes to "in progress" and the loop asks
again under its existing timeout.

## [0.8.0] — 2026-07-18

### Changed (BREAKING)
Expand Down
26 changes: 23 additions & 3 deletions src/main/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflow.java
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ private ResultMap pollExecution(
() -> backtestingApi.getBacktestResult(req.exchangeId(), TICKER, executeJobId),
"Execution result request failed",
QTSExecutionError::new),
r -> StatusNormalizer.normalize(r.getState().getStatus()) == Normalized.IN_PROGRESS);
r -> StatusNormalizer.normalize(statusOf(r)) == Normalized.IN_PROGRESS);

Normalized norm = StatusNormalizer.normalize(finalResult.getState().getStatus());
Normalized norm = StatusNormalizer.normalize(statusOf(finalResult));
if (norm == Normalized.FAILED) {
throw new QTSExecutionError(statusDetailOrDefault(finalResult.getState().getStatusDetail(), "Execution failed"));
}
Expand All @@ -195,7 +195,7 @@ private ResultMap pollExecution(
ResultMap results = finalResult.getResults();
log.info("Execution result for job {}: state={} instrument={} strategyId={} pnl={} trades={}",
executeJobId,
finalResult.getState().getStatus(),
statusOf(finalResult),
results != null ? results.getInstrument() : null,
results != null ? results.getStrategyId() : null,
results != null ? results.getPnlTotal() : null,
Expand Down Expand Up @@ -328,6 +328,26 @@ private static String statusDetailOrDefault(String detail, String fallback) {
return (detail == null || detail.isBlank()) ? fallback : detail;
}

/**
* Status of an execute-result response, or {@code null} when it carries no state.
*
* <p>The API answers {@code 202} with an empty body when a job is known but its result is not
* readable yet, so {@code getState()} is legitimately null on a successful response. Reading
* the status through this instead of dereferencing directly is what keeps a 202 a poll:
* {@link StatusNormalizer#normalize} maps null to {@code IN_PROGRESS}, so the loop asks again
* under its existing timeout.
*
* <p>Dereferencing directly does not fail loudly, which is why this is easy to reintroduce.
* The {@code NullPointerException} is raised inside Failsafe's result predicate, where it is
* swallowed: the retry simply does not match, the poll <em>ends</em>, and the caller is handed
* a null {@code ResultMap} — the same "finished, and I could not find it" trap the 202 exists
* to prevent, moved to the client side. Verified by reverting this guard: the regression test
* fails with a null result, not with a visible NPE.
*/
private static JobState.StatusEnum statusOf(BacktestJobResult result) {
return result == null || result.getState() == null ? null : result.getState().getStatus();
}

private static Throwable unwrap(Throwable t) {
if (t instanceof java.util.concurrent.CompletionException && t.getCause() != null) {
return t.getCause();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
Expand Down Expand Up @@ -116,6 +117,48 @@ void runsHappyPathAndReturnsResultMap() throws Exception {
assertEquals("strategy-abc", execBody.getValue().getStrategyId());
}

@Test
void keepsPollingThroughAnEmpty202AndResolvesOnceTheResultIsReadable() throws Exception {
// The API answers 202 with an empty body when a job is known but its result is not
// readable yet, so a successful response can legitimately carry no state at all.
// Dereferencing getState() in the retry predicate ends the poll instead of continuing it
// — the exception is swallowed by the retry policy, so the caller silently receives a
// null ResultMap for a backtest that actually completed.
when(strategyClient.submit("class S {}")).thenReturn("compile-job-1");
when(strategyClient.status("compile-job-1"))
.thenReturn(new CompileStatus(Normalized.COMPLETED, "strategy-abc", null));

when(backtestingApi.prepareBacktest(eq("binance"), eq(DataSourceType.TICKER), any(PrepareRequest.class)))
.thenReturn(new AcceptedJob().jobId("prep-1"));
when(backtestingApi.getPrepareStatus("binance", DataSourceType.TICKER, "prep-1"))
.thenReturn(new PrepareJobState().status(PrepareJobState.StatusEnum.COMPLETED).size(1).completed(1));

when(backtestingApi.executeBacktest(eq("binance"), eq(DataSourceType.TICKER), any(ExecuteBacktestRequest.class)))
.thenReturn(new AcceptedJob().jobId("exec-202"));

ResultMap resultMap = new ResultMap()
.strategyId("strategy-abc")
.instrument("BTC/USDT")
.pnlTotal(7.0);
when(backtestingApi.getBacktestResult("binance", DataSourceType.TICKER, "exec-202"))
.thenReturn(new BacktestJobResult()) // 202: empty body, no state
.thenReturn(new BacktestJobResult())
.thenReturn(new BacktestJobResult()
.state(new JobState().status(JobState.StatusEnum.COMPLETED).size(1).completed(1))
.results(resultMap));

BacktestOptions opts = BacktestOptions.builder()
.pollInterval(Duration.ofMillis(1))
.maxPollInterval(Duration.ofMillis(2))
.build();

ResultMap result = workflow.runFull(REQ, opts).get(10, TimeUnit.SECONDS);

assertEquals("strategy-abc", result.getStrategyId());
assertEquals(7.0, result.getPnlTotal());
verify(backtestingApi, atLeast(3)).getBacktestResult("binance", DataSourceType.TICKER, "exec-202");
}

@Test
void throwsQTSStrategyCompileErrorWhenSubmitFails() {
doThrow(new QTSStrategyCompileError("bad source")).when(strategyClient).submit(anyString());
Expand Down
Loading