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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Pending

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

## 1.5.0

### Update:
Expand Down
65 changes: 65 additions & 0 deletions src/main/java/org/stellar/sdk/SorobanServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.IntFunction;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
Expand Down Expand Up @@ -298,6 +299,63 @@ public GetTransactionResponse getTransaction(String hash) {
"getTransaction", params, new TypeToken<SorobanRpcResponse<GetTransactionResponse>>() {});
}

/**
* An alias for {@link #pollTransaction(String, int, SleepStrategy)} with default parameters,
* which {@code maxAttempts} is set to 30, and the sleep strategy is set to a default strategy
* that sleeps for 1 second between attempts.
*
* @param hash The hash of the transaction to poll for.
* @return A {@link GetTransactionResponse} object after a "found" response, (which may be success
* or failure) or the last response obtained after polling the maximum number of specified
* attempts.
* @throws InterruptedException If the thread is interrupted while sleeping between attempts.
*/
public GetTransactionResponse pollTransaction(String hash) throws InterruptedException {
return pollTransaction(
hash,
30,
attempt -> {
return 1_000L;
});
}

/**
* Polls the transaction status until it is completed or the maximum number of attempts is
* reached.
*
* <p>After submitting a transaction, clients can use this to poll for transaction completion and
* return a definitive state of success or failure.
*
* @param hash The hash of the transaction to poll for.
* @param maxAttempts The number of attempts to make before returning the last-seen status.
* @param sleepStrategy A strategy to determine the sleep duration between attempts. It should
* take the current attempt number and return the sleep duration in milliseconds.
* @return A {@link GetTransactionResponse} object after a "found" response, (which may be success
* or failure) or the last response obtained after polling the maximum number of specified
* attempts.
* @throws IllegalArgumentException If maxAttempts is less than or equal to 0.
* @throws InterruptedException If the thread is interrupted while sleeping between attempts.
*/
public GetTransactionResponse pollTransaction(
String hash, int maxAttempts, SleepStrategy sleepStrategy) throws InterruptedException {
if (maxAttempts <= 0) {
throw new IllegalArgumentException("maxAttempts must be greater than 0");
}

int attempts = 0;
GetTransactionResponse getTransactionResponse = null;
while (attempts < maxAttempts) {
getTransactionResponse = getTransaction(hash);
if (!GetTransactionResponse.GetTransactionStatus.NOT_FOUND.equals(
getTransactionResponse.getStatus())) {
return getTransactionResponse;
}
attempts++;
TimeUnit.MILLISECONDS.sleep(sleepStrategy.apply(attempts));
}
return getTransactionResponse;
}

/**
* Gets a detailed list of transactions starting from the user specified starting point that you
* can paginate as long as the pages fall within the history retention of their corresponding RPC
Expand Down Expand Up @@ -754,4 +812,11 @@ public enum Durability {
TEMPORARY,
PERSISTENT
}

/** Strategy for sleeping between retries in a retry loop. */
@FunctionalInterface
public interface SleepStrategy extends IntFunction<Long> {
// apply as in apply(iterationNumber) -> millisecondsToSleep
Long apply(int iteration);
}
}
48 changes: 48 additions & 0 deletions src/test/java/org/stellar/sdk/SorobanServerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,54 @@ public MockResponse dispatch(@NotNull RecordedRequest recordedRequest)
mockWebServer.close();
}

@Test
public void testPollTransaction() throws IOException, SorobanRpcException, InterruptedException {
String txSuccessFilePath = "src/test/resources/soroban_server/get_transaction.json";
String txNotFoundFilePath = "src/test/resources/soroban_server/get_transaction_not_found.json";
String txSuccessJson = new String(Files.readAllBytes(Paths.get(txSuccessFilePath)));
String txNotFoundJson = new String(Files.readAllBytes(Paths.get(txNotFoundFilePath)));
String hash = "06dd9ee70bf93bbfe219e2b31363ab5a0361cc6285328592e4d3d1fed4c9025c";
MockWebServer mockWebServer = new MockWebServer();
Dispatcher dispatcher =
new Dispatcher() {
private int attempts = 0;

@NotNull
@Override
public MockResponse dispatch(@NotNull RecordedRequest recordedRequest)
throws InterruptedException {
GetTransactionRequest expectedRequest = new GetTransactionRequest(hash);
SorobanRpcRequest<GetTransactionRequest> sorobanRpcRequest =
gson.fromJson(
recordedRequest.getBody().readUtf8(),
new TypeToken<SorobanRpcRequest<GetTransactionRequest>>() {}.getType());
if ("POST".equals(recordedRequest.getMethod())
&& sorobanRpcRequest.getMethod().equals("getTransaction")
&& sorobanRpcRequest.getParams().equals(expectedRequest)) {
attempts++;
if (attempts > 3) {
return new MockResponse().setResponseCode(200).setBody(txSuccessJson);
} else {
return new MockResponse().setResponseCode(200).setBody(txNotFoundJson);
}
}
return new MockResponse().setResponseCode(404);
}
};
mockWebServer.setDispatcher(dispatcher);
mockWebServer.start();

HttpUrl baseUrl = mockWebServer.url("");
SorobanServer server = new SorobanServer(baseUrl.toString());
GetTransactionResponse tx = server.pollTransaction(hash);
assertEquals(tx.getStatus(), GetTransactionResponse.GetTransactionStatus.SUCCESS);
assertEquals(
tx.getTxHash(), "8faa3e6bb29d9d8469bbcabdbfd800f3be1899f4736a3a2fa83cd58617c072fe");

server.close();
mockWebServer.close();
}

@Test
public void testGetTransactions() throws IOException, SorobanRpcException {
String filePath = "src/test/resources/soroban_server/get_transactions.json";
Expand Down
16 changes: 16 additions & 0 deletions src/test/resources/soroban_server/get_transaction_not_found.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"jsonrpc": "2.0",
"id": "198cb1a8-9104-4446-a269-88bf000c2721",
"result": {
"latestLedger": 57612528,
"latestLedgerCloseTime": "1750300167",
"oldestLedger": 57491569,
"oldestLedgerCloseTime": "1749616481",
"status": "NOT_FOUND",
"txHash": "8faa3e6bb29d9d8469bbcabdbfd800f3be1899f4736a3a2fa83cd58617c072fe",
"applicationOrder": 0,
"feeBump": false,
"ledger": 0,
"createdAt": "0"
}
}