Skip to content

submitTransaction/submitAsyncTransaction reject with the raw HTTP-client error (AxiosError) instead of BadResponseError #1437

Description

@oceans404

Summary

A failed Horizon.Server.submitTransaction(tx) rejects with the raw HTTP-client
error
(an AxiosError), not the documented BadResponseError / NetworkError.
So TypeScript consumers get no typed error for the most common write-failure path,
must read result codes at error.response.data.extras.result_codes, and can't
rely on instanceof NetworkError/BadResponseError.

This is long-standing (reproduces on published majors v12.0.0 through
v15.1.0) and is also present on modernization. It is not a regression.

How this was found

While writing the new error-handling developer guide, whose snippets are
type-checked and run as a real consumer. Catching a failed submission showed the
error is an AxiosError, not the SDK error the docs describe.

Root cause

submitTransaction (and submitAsyncTransaction) catch blocks lead with an
instanceof Error guard before the BadResponseError wrapping
(src/horizon/server.ts):

.catch((response) => {
  if (response instanceof Error) {
    return Promise.reject(response);          // <-- always taken
  }
  return Promise.reject(
    new BadResponseError(
      `Transaction submission failed. Server responded: ${response.status} ${response.statusText}`,
      response.data,
    ),
  );                                          // <-- dead code
});

The HTTP client always rejects a non-2xx with an Error subclass — feaxios's
AxiosError in the default (fetch) build, real axios's AxiosError in the
opt-in axios build, or a plain Error from the fetch bounded adapter. So the
guard always short-circuits and the BadResponseError branch is unreachable.

submitTransaction also bypasses CallBuilder, so it never benefits from the
typed-error mapping (_handleNetworkError) that the read endpoints use.

Impact / blast radius

Affected methods (same short-circuit pattern):

  • Horizon.Server.submitTransaction
  • Horizon.Server.submitAsyncTransaction
  • Federation.Server._sendRequest → public resolveAddress,
    resolveAccountId, resolveTransactionId, forDomain

Not affected: all Horizon reads (loadAccount, every CallBuilder.call() /
.next()) — they route through CallBuilder._handleNetworkError, which correctly
rejects with NotFoundError / BadRequestError / NetworkError.

Both builds behave identically (default fetch/feaxios and the opt-in axios
build both reject with an Error subclass), so this is not client-specific.

Error-shape inconsistency across the API (a second, related problem):

Path Error type Horizon body location
submit raw AxiosError error.response.data (e.g. error.response.data.extras.result_codes)
reads (loadAccount, call builders) SDK NotFoundError/NetworkError spread on error.response (e.g. error.response.title)

And neither matches the documented NetworkError.response shape
({ data?, status?, statusText?, url? } per src/errors/network.ts): submit
leaks the axios envelope, and the read path sets .response to the raw Horizon
body. So the documented response.data / response.statusText fields are absent
on both paths.

Reproduction

// fund `sender` via friendbot, then pay an account that does not exist:
const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: Networks.TESTNET })
  .addOperation(Operation.payment({ destination: Keypair.random().publicKey(), asset: Asset.native(), amount: "1" }))
  .setTimeout(30).build();
tx.sign(sender);
try {
  await horizon.submitTransaction(tx);
} catch (error) {
  console.log(error.constructor.name);                 // "AxiosError"  (not BadResponseError)
  console.log(error instanceof NetworkError);          // false
  console.log(error.response.data.extras.result_codes); // { transaction: "tx_failed", operations: ["op_no_destination"] }
}

Suggested fix

Fix the submit catch blocks (not the HTTP client). Detect the HTTP-error shape
(e.g. a present error.response?.status / error.isAxiosError) and wrap it:

.catch((error) => {
  if (error.response) {
    return Promise.reject(
      new BadResponseError(
        `Transaction submission failed. Server responded: ${error.response.status} ${error.response.statusText}`,
        error.response.data,
      ),
    );
  }
  return Promise.reject(error); // genuine non-HTTP failure (setup/network)
});

so result codes land at the documented location and instanceof NetworkError
works. Or route submit through the same _handleNetworkError mapping the read
endpoints use, for consistency. Separately, reconcile the documented
NetworkError.response envelope with what the code actually sets on both paths.

Environment

  • Long-standing across published majors v12.0.0 – v15.1.0, and present on
    modernization.
  • Default build uses fetch (feaxios); axios is opt-in via the /axios subpath.
    Behavior is identical in both.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions