Skip to main content

Error Handling

It’s important to anticipate errors your users may encounter as you develop on Stellar. In many tutorials throughout our developer documentation, we leave out error handling code to focus on the example. In this section, we will do the opposite and talk specifically about the errors. By the end of this section, you should be able to categorize errors and understand the best way to handle them in your application.

Many actions interact with the Stellar network through the Horizon API, and these possible actions fall into two main categories:

  1. Queries (any GET request, like to /accounts)
  2. Transaction submissions (a POST /transactions, POST /transactions_async).

There are many possible error codes when executing these actions, and you can typically handle these error codes using the following strategies:

  • Request adjustments: adjusting the request to resolve structural errors with queries or transaction submissions. Suppose you’ve included a bad parameter, malformed your XDR, or otherwise didn’t follow the endpoint’s specification. In these cases, resolve the error by referencing the details or result codes of the error response.
  • Polling and retrying: this is the recommended way to work around latency or congestion issues encountered along the pipeline between your computer and the Stellar network, which can sometimes happen due to the nature of distributed systems.

Error Handling for Queries

Many GET requests have specific parameter requirements, and while the SDKs can help enforce them, you can still pass invalid arguments (for example, an asset string that isn’t SEP-11 compatible) that error out every time. In this scenario, there’s nothing you can do aside from following the API specification. The extras field of the error response will often clue you in on where to look and what to look for.

curl -s https://horizon-testnet.stellar.org/claimable_balances/0000 | jq '.extras'
{
"invalid_field": "id",
"reason": "Invalid claimable balance ID"
}

Note that the SDKs make it a point to distinguish an invalid request (as above) versus a missing resource (a 404 Not Found) (for example, the generic NetworkError versus a NotFoundError in the JavaScript SDK), where the latter might not be considered an error depending on your situation.

Error Handling for Transaction Submissions

Horizon currently supports two types of transaction submission endpoints:

  1. /transactions_async: Horizon submits a transaction to Stellar-Core in an asynchronous manner and passes the relevant Stellar-Core response back immediately to the client. It is then the client's responsibility to poll for the status of that transaction.
  2. /transactions: Horizon submits a transaction to Stellar-Core and then waits for it to be ingested into its database. It will either return a success (200), failure (400) or a timeout response (504), after which it is the client's responsibility to poll for the status of the transaction.

Note that polling the transaction hash will return a 404 until it gets included in the ledger, or it fails to do so. In both cases, you will get a response when Horizon has ingested it into the database.

There are some resolution strategies that are common between the 2 endpoints while others strategies are more endpoint specific.

Request Adjustments

Certain transaction submission failures also need adjustments to succeed.

  • If the XDR is malformed, or the transaction is otherwise invalid, you’ll encounter a 400 Bad Request (for example, an invalid source account). Both transactions and their operations can be easily malformed or invalid: look at the extras.result_codes field for details and cross-reference them with the appropriate result codes documentation to determine specifics.
  • Transaction fees are also a safe adjustment by modifying the fees via a fee-bump transaction if you get a tx_insufficient_fee error. Refer to the Insufficient Fees and Surge Pricing section later in this document for more information on managing fees and strategies around it.

Polling and Retrying Transactions

Async Transaction Submission

Submissions using the /transactions_async endpoint return an immediate response back from Stellar-Core. There are different actions that clients can take based on the specific tx_status returned:

  1. PENDING: The submission is successful but the transaction is still waiting to be included in a ledger. You should use the GET /transactions/:transaction_hash endpoint to poll for the submitted transaction and check if it makes into a ledger. Note that even though the submission was successful, it can still fail to get included in the ledger.
  2. ERROR: The submission did not go through due to an error in Stellar-Core. Take a look at the attached error message for more details, modify your transaction if necessary, and resubmit.
  3. TRY_AGAIN_LATER: This indicates that the Stellar-Core instance is currently unable to process submission of this particular transaction. Clients should wait for sometime before resubmitting the transaction. This could happen due to different reasons:
    • There is another transaction from same source account in memory
    • It has been rejected due to too low fee and has been resubmitted too soon
    • The transaction has resource consumption higher than the network limits allow: clients should wait for sometime before retrying it again.
let server = sdk.Server("https://horizon-testnet.stellar.org");
let contractId = "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE";
let contract = new StellarSdk.Contract(contractId);

// Right now, this is just the default fee for this example.
const fee = StellarSdk.BASE_FEE;

let transaction = new StellarSdk.TransactionBuilder(account, { fee })
.setNetworkPassphrase(StellarSdk.Networks.TESTNET)
.setTimeout(30) // valid for the next 30s
// Add an operation to call increment() on the contract
.addOperation(contract.call("increment"))
.build();

// Sign this transaction with the secret key
// NOTE: signing is transaction is network specific. Test network transactions
// won't work in the public network. To switch networks, use the Network object
// as explained above (look for StellarSdk.Network).
let sourceKeypair = StellarSdk.Keypair.fromSecret(sourceSecretKey);
transaction.sign(sourceKeypair);

server.submitAsyncTransaction(transaction).then((result) => {
console.log("hash:", result.hash);
console.log("status:", result.tx_status);
console.log("errorResultXdr:", result.error_result_xdr);
});

// Add a small sleep duration before polling the transaction.
time.sleep(5 * time.Second);
server
.transactions()
.transaction(result.hash)
.call()
.then((txResult) => {
console.log("Transaction status:", txResult);
});

Synchronous Transaction Submission

Due to the blocking nature of this endpoint, things are a little different compared to the asynchronous strategy. There are 3 possible scenarios that clients can encounter:

  1. The submission is successful and Horizon returns the transaction response back. This is the happy path and clients do not need to do anything but wait for Horizon's response.
  2. Stellar-Core sends back an ERROR response from the submission. Clients should consult the attached error message and retry the submission again.
  3. Timeouts: Horizon may respond with a 504 HTTP code. This response is not an error but a warning that your transaction hasn't been accepted by the network yet. There could be many possible reasons for the timeout, the most common of which is network congestion, but it could also be due to other transient issues.
    1. Polling the transaction hash: Use the transaction hash in the timeout response and poll the GET /transactions/:transaction_hash endpoint to see if it successfully makes it into a ledger.
    2. Resubmitting the transaction: Before attempting any resubmissions, you need to make sure your transaction has timed out based on the time bounds you specified. After your transaction has expired, you can confirm it by polling the transaction again and getting a tx_too_late response from Horizon. Rebuild the transaction by updating the timebounds and resubmit the transaction.
caution

Do note that resubmitting a transaction is only safe when it is unchanged - same operations, signatures, sequence number, etc... Be careful when working around an error that does require changes to the transaction. It can cause duplicate transactions, which can cause problems - double payments, incorrect trustlines, and more. If you continue to face timeouts on retries, consider using a fee-bump transaction to get into the ledger (after the initial transaction's timebound expires) or increasing the maximum fee you’re willing to pay. Read up on Surge Pricing and Fee Strategies for more details.

Example: Using Time Bounds

Timebounds are optional but highly recommended as they put a definitive time limit on the transaction's finality - after it times out, you will know for sure whether it made it into a ledger. For example, you submit a transaction, and it enters the queue of the Stellar network, but Horizon crashes while giving you a response. Uncertain about the transaction status, you resubmit the transaction (with no changes!) until either (a) Horizon comes back up to give you a reply or (b) your time bounds are exceeded.

There are only two possible results to this scenario: either the transaction makes it into the ledger (exactly once) and Horizon gives you the response, or the transaction never makes it out of the queue, and you receive the corresponding tx_too_late response.

Example implementation:

import { Horizon } from "@stellar/stellar-sdk";

let server = Horizon.Server("https://horizon-testnet.stellar.org");

function submitTransaction(tx, timeout) {
if (!tx.timeBounds || tx.timeBounds.maxTime === 0) {
throw new Error("Always set a reasonable timebound!");
}
const expiration = parseInt(tx.timeBounds.maxTime);

return server.submitTransaction(tx).catch(function (error) {
if (isNonRetryErrorCase(error)) {
// ...do other error handling...
return;
}

// the tx no longer has a chance of making it into a ledger
if (Date.now() >= expiration) {
return new Error("The transaction timed out.");
}

timeout = timeout || 1; // start the (linear) back-off process
return sleep(timeout).then(function () {
return submitTransaction(tx, timeout + 5);
});
});
}

We assume the existence of a sleep implementation similar to the one here. Be sure to integrate backoff into your retry mechanism. In our example error-handling code above, we implement a simple linear backoff, but there are plenty of recommendations for various other strategies. Backoff is important both for maintaining performance and avoiding rate-limiting issues.

Example: Invalid Sequence Numbers

These errors typically occur when you have an outdated view of an account. This could be because multiple devices are using this account, you have concurrent submissions happening, or other reasons. The solution is relatively simple: retrieve the account details and try again with an updated sequence number.

// suppose `account` is an outdated `AccountResponse` object
let tx = sdk.TransactionBuilder(account, ...)/* etc */.build();
server.submitTransaction(tx).catch(function (error) {
if (error.response && error.status == 400 && error.extras &&
error.extras.result_codes.transaction == sdk.TX_BAD_SEQ) {
return server.loadAccount(account.accountId())
.then(function (response) {
let tx = sdk.TransactionBuilder(response, ...)/* etc */.build()
return server.submitTransaction(tx);
});
}
// ...other error conditions...
})

Despite the solution’s simplicity, things can go wrong fast if you don’t understand why the error occurred.

Suppose you submit transactions from multiple places in your application simultaneously, and your user spammed a Send Payment button a few times in their impatience. If you send the exact same payment transaction for each tap, naturally, only one will succeed. The others will fail with an invalid sequence number (tx_bad_seq), and if you resubmit blindly with an updated sequence number (as we do above), these payments will also succeed, resulting in more than one payment being made when only one was intended. So be very careful when resubmitting transactions that have been modified to work around an error.

Managing specific Errors

Here, we will cover specific errors commonly encountered during transaction submission and direct you to the appropriate resolution.

ResultCodeDescription
FAILED-1One of the operations failed (see List of Operations for errors)
TOO_EARLY-2Ledger closeTime before minTime value in the transaction
TOO_LATE-3Ledger closeTime after maxTime value in the transaction
MISSING_OPERATION-4No operation was specified
BAD_SEQ-5Sequence number does not match source account
BAD_AUTH-6Too few valid signatures / wrong network
INSUFFICIENT_BALANCE-7Fee would bring account below minimum balance; see our section on Lumens for more info
NO_ACCOUNT-8Source account not found
INSUFFICIENT_FEE-9Fee is too small; see our section on Fees for more info
BAD_AUTH_EXTRA-10Unused signatures attached to transaction
INTERNAL_ERROR-11An unknown error occurred
NOT_SUPPORTED-12The transaction type is not supported
FEE_BUMP_INNER_FAILED-13The inner transaction of a fee-bump transaction failed
BAD_SPONSORSHIP-14The sponsorship is not confirmed

Insufficient fees and surge pricing

See the Fees section

Rate limiting

Horizon may rate limit requests and return 429 Too Many Requests error when exceeding the rate limits. If using your own instance, you may want to either increase or disable rate limiting. If you're using a third-party Horizon instance, you may want to deploy your own to have more control over this configuration or send requests less frequently.

Insufficient XLM balance

Any transaction that would reduce an account’s balance to less than the minimum will be rejected with an INSUFFICIENT_BALANCE error. Likewise, lumen selling liabilities that would reduce an account’s balance to less than the minimum plus lumen selling liabilities will be rejected with an INSUFFICIENT_BALANCE error.

For more on minimum balances, see our Lumens section.