From bd7cc99cf41b82a63d88ac9a708b87be97ce5864 Mon Sep 17 00:00:00 2001 From: Manuel Polo Date: Sun, 26 Jul 2026 23:52:53 +0200 Subject: [PATCH] fix(backtest): a 202 on the execute poll no longer yields 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. The failure is quiet, which is what makes it worth a regression test. The NullPointerException is raised inside Failsafe's result predicate and swallowed there: the retry does not match, the poll ends, and the caller is handed a null ResultMap for a backtest that actually completed -- the same "finished, and I could not find it" trap the 202 exists to prevent, moved to the client side. 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. Verified fail-first: reverting the guard fails the new test with a null result, not with a visible NPE. --- CHANGELOG.md | 11 +++++ .../api/sdk/workflows/BacktestWorkflow.java | 26 +++++++++-- .../sdk/workflows/BacktestWorkflowTest.java | 43 +++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4986b0a..25da367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/main/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflow.java b/src/main/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflow.java index 59d2770..1ff684b 100644 --- a/src/main/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflow.java +++ b/src/main/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflow.java @@ -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")); } @@ -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, @@ -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. + * + *

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. + * + *

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 ends, 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(); diff --git a/src/test/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflowTest.java b/src/test/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflowTest.java index 5f4ef22..30e4201 100644 --- a/src/test/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflowTest.java +++ b/src/test/java/com/qtsurfer/api/sdk/workflows/BacktestWorkflowTest.java @@ -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; @@ -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());