Skip to content

Commit 2989e9b

Browse files
authored
feat: add pollTransaction method to SorobanServer to poll transaction status with retry strategy. (#696)
1 parent dc2b705 commit 2989e9b

File tree

4 files changed

+132
-0
lines changed

4 files changed

+132
-0
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Pending
44

5+
### Update:
6+
- feat: add `pollTransaction` method to `SorobanServer` to poll transaction status with retry strategy. ([#696](https://github.com/stellar/java-stellar-sdk/pull/696))
7+
58
## 1.5.0
69

710
### Update:

src/main/java/org/stellar/sdk/SorobanServer.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.util.Optional;
1515
import java.util.UUID;
1616
import java.util.concurrent.TimeUnit;
17+
import java.util.function.IntFunction;
1718
import okhttp3.HttpUrl;
1819
import okhttp3.MediaType;
1920
import okhttp3.OkHttpClient;
@@ -298,6 +299,63 @@ public GetTransactionResponse getTransaction(String hash) {
298299
"getTransaction", params, new TypeToken<SorobanRpcResponse<GetTransactionResponse>>() {});
299300
}
300301

302+
/**
303+
* An alias for {@link #pollTransaction(String, int, SleepStrategy)} with default parameters,
304+
* which {@code maxAttempts} is set to 30, and the sleep strategy is set to a default strategy
305+
* that sleeps for 1 second between attempts.
306+
*
307+
* @param hash The hash of the transaction to poll for.
308+
* @return A {@link GetTransactionResponse} object after a "found" response, (which may be success
309+
* or failure) or the last response obtained after polling the maximum number of specified
310+
* attempts.
311+
* @throws InterruptedException If the thread is interrupted while sleeping between attempts.
312+
*/
313+
public GetTransactionResponse pollTransaction(String hash) throws InterruptedException {
314+
return pollTransaction(
315+
hash,
316+
30,
317+
attempt -> {
318+
return 1_000L;
319+
});
320+
}
321+
322+
/**
323+
* Polls the transaction status until it is completed or the maximum number of attempts is
324+
* reached.
325+
*
326+
* <p>After submitting a transaction, clients can use this to poll for transaction completion and
327+
* return a definitive state of success or failure.
328+
*
329+
* @param hash The hash of the transaction to poll for.
330+
* @param maxAttempts The number of attempts to make before returning the last-seen status.
331+
* @param sleepStrategy A strategy to determine the sleep duration between attempts. It should
332+
* take the current attempt number and return the sleep duration in milliseconds.
333+
* @return A {@link GetTransactionResponse} object after a "found" response, (which may be success
334+
* or failure) or the last response obtained after polling the maximum number of specified
335+
* attempts.
336+
* @throws IllegalArgumentException If maxAttempts is less than or equal to 0.
337+
* @throws InterruptedException If the thread is interrupted while sleeping between attempts.
338+
*/
339+
public GetTransactionResponse pollTransaction(
340+
String hash, int maxAttempts, SleepStrategy sleepStrategy) throws InterruptedException {
341+
if (maxAttempts <= 0) {
342+
throw new IllegalArgumentException("maxAttempts must be greater than 0");
343+
}
344+
345+
int attempts = 0;
346+
GetTransactionResponse getTransactionResponse = null;
347+
while (attempts < maxAttempts) {
348+
getTransactionResponse = getTransaction(hash);
349+
if (!GetTransactionResponse.GetTransactionStatus.NOT_FOUND.equals(
350+
getTransactionResponse.getStatus())) {
351+
return getTransactionResponse;
352+
}
353+
attempts++;
354+
TimeUnit.MILLISECONDS.sleep(sleepStrategy.apply(attempts));
355+
}
356+
return getTransactionResponse;
357+
}
358+
301359
/**
302360
* Gets a detailed list of transactions starting from the user specified starting point that you
303361
* can paginate as long as the pages fall within the history retention of their corresponding RPC
@@ -754,4 +812,11 @@ public enum Durability {
754812
TEMPORARY,
755813
PERSISTENT
756814
}
815+
816+
/** Strategy for sleeping between retries in a retry loop. */
817+
@FunctionalInterface
818+
public interface SleepStrategy extends IntFunction<Long> {
819+
// apply as in apply(iterationNumber) -> millisecondsToSleep
820+
Long apply(int iteration);
821+
}
757822
}

src/test/java/org/stellar/sdk/SorobanServerTest.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,54 @@ public MockResponse dispatch(@NotNull RecordedRequest recordedRequest)
514514
mockWebServer.close();
515515
}
516516

517+
@Test
518+
public void testPollTransaction() throws IOException, SorobanRpcException, InterruptedException {
519+
String txSuccessFilePath = "src/test/resources/soroban_server/get_transaction.json";
520+
String txNotFoundFilePath = "src/test/resources/soroban_server/get_transaction_not_found.json";
521+
String txSuccessJson = new String(Files.readAllBytes(Paths.get(txSuccessFilePath)));
522+
String txNotFoundJson = new String(Files.readAllBytes(Paths.get(txNotFoundFilePath)));
523+
String hash = "06dd9ee70bf93bbfe219e2b31363ab5a0361cc6285328592e4d3d1fed4c9025c";
524+
MockWebServer mockWebServer = new MockWebServer();
525+
Dispatcher dispatcher =
526+
new Dispatcher() {
527+
private int attempts = 0;
528+
529+
@NotNull
530+
@Override
531+
public MockResponse dispatch(@NotNull RecordedRequest recordedRequest)
532+
throws InterruptedException {
533+
GetTransactionRequest expectedRequest = new GetTransactionRequest(hash);
534+
SorobanRpcRequest<GetTransactionRequest> sorobanRpcRequest =
535+
gson.fromJson(
536+
recordedRequest.getBody().readUtf8(),
537+
new TypeToken<SorobanRpcRequest<GetTransactionRequest>>() {}.getType());
538+
if ("POST".equals(recordedRequest.getMethod())
539+
&& sorobanRpcRequest.getMethod().equals("getTransaction")
540+
&& sorobanRpcRequest.getParams().equals(expectedRequest)) {
541+
attempts++;
542+
if (attempts > 3) {
543+
return new MockResponse().setResponseCode(200).setBody(txSuccessJson);
544+
} else {
545+
return new MockResponse().setResponseCode(200).setBody(txNotFoundJson);
546+
}
547+
}
548+
return new MockResponse().setResponseCode(404);
549+
}
550+
};
551+
mockWebServer.setDispatcher(dispatcher);
552+
mockWebServer.start();
553+
554+
HttpUrl baseUrl = mockWebServer.url("");
555+
SorobanServer server = new SorobanServer(baseUrl.toString());
556+
GetTransactionResponse tx = server.pollTransaction(hash);
557+
assertEquals(tx.getStatus(), GetTransactionResponse.GetTransactionStatus.SUCCESS);
558+
assertEquals(
559+
tx.getTxHash(), "8faa3e6bb29d9d8469bbcabdbfd800f3be1899f4736a3a2fa83cd58617c072fe");
560+
561+
server.close();
562+
mockWebServer.close();
563+
}
564+
517565
@Test
518566
public void testGetTransactions() throws IOException, SorobanRpcException {
519567
String filePath = "src/test/resources/soroban_server/get_transactions.json";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"jsonrpc": "2.0",
3+
"id": "198cb1a8-9104-4446-a269-88bf000c2721",
4+
"result": {
5+
"latestLedger": 57612528,
6+
"latestLedgerCloseTime": "1750300167",
7+
"oldestLedger": 57491569,
8+
"oldestLedgerCloseTime": "1749616481",
9+
"status": "NOT_FOUND",
10+
"txHash": "8faa3e6bb29d9d8469bbcabdbfd800f3be1899f4736a3a2fa83cd58617c072fe",
11+
"applicationOrder": 0,
12+
"feeBump": false,
13+
"ledger": 0,
14+
"createdAt": "0"
15+
}
16+
}

0 commit comments

Comments
 (0)