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.
Summary
A failed
Horizon.Server.submitTransaction(tx)rejects with the raw HTTP-clienterror (an
AxiosError), not the documentedBadResponseError/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'trely 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(andsubmitAsyncTransaction) catch blocks lead with aninstanceof Errorguard before theBadResponseErrorwrapping(
src/horizon/server.ts):The HTTP client always rejects a non-2xx with an
Errorsubclass —feaxios'sAxiosErrorin the default (fetch) build, realaxios'sAxiosErrorin theopt-in axios build, or a plain
Errorfrom the fetch bounded adapter. So theguard always short-circuits and the
BadResponseErrorbranch is unreachable.submitTransactionalso bypassesCallBuilder, so it never benefits from thetyped-error mapping (
_handleNetworkError) that the read endpoints use.Impact / blast radius
Affected methods (same short-circuit pattern):
Horizon.Server.submitTransactionHorizon.Server.submitAsyncTransactionFederation.Server._sendRequest→ publicresolveAddress,resolveAccountId,resolveTransactionId,forDomainNot affected: all Horizon reads (
loadAccount, everyCallBuilder.call()/.next()) — they route throughCallBuilder._handleNetworkError, which correctlyrejects with
NotFoundError/BadRequestError/NetworkError.Both builds behave identically (default fetch/feaxios and the opt-in axios
build both reject with an
Errorsubclass), so this is not client-specific.Error-shape inconsistency across the API (a second, related problem):
AxiosErrorerror.response.data(e.g.error.response.data.extras.result_codes)loadAccount, call builders)NotFoundError/NetworkErrorerror.response(e.g.error.response.title)And neither matches the documented
NetworkError.responseshape(
{ data?, status?, statusText?, url? }persrc/errors/network.ts): submitleaks the axios envelope, and the read path sets
.responseto the raw Horizonbody. So the documented
response.data/response.statusTextfields are absenton both paths.
Reproduction
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:so result codes land at the documented location and
instanceof NetworkErrorworks. Or route submit through the same
_handleNetworkErrormapping the readendpoints use, for consistency. Separately, reconcile the documented
NetworkError.responseenvelope with what the code actually sets on both paths.Environment
modernization.feaxios); axios is opt-in via the/axiossubpath.Behavior is identical in both.