Error Codes
This document provides a comprehensive reference for all error codes in the Hydra API.
gRPC Status Codes
Hydra uses standard gRPC status codes. All errors follow this format:
{
"code": 3,
"message": "Invalid request: unknown sending asset",
"details": ["<one hydra_app.ErrorDetail, below>"]
}
The error detail
(2026-10-14) An error Hydra App answers a call with carries, beside its code and its message, an ErrorDetail whenever the node knows more than the two say: every refusal of its own — including a field missing, one that does not parse, a record it holds none of, and a call this build does not offer — every failure a network's node gives a reason for — a balance or a channel short of what the call needs, a peer not connected, a chain out of reach, a refused fee bump — and every failure it can say more of. A failure it relays from a service without a reason, and an error the transport answers before a method runs (a body that does not decode, a method that does not exist), carry none.
| Field | Type | Description |
|---|---|---|
reason | FailureReason | Why the call failed: a closed set to match on instead of the message — see Reasons. Absent (FAILURE_REASON_UNSPECIFIED) when the node does not know |
retry | RetryAdvice | What clears the failure: RETRY_ADVICE_RESEND — send the same request again, nothing about it is wrong; RETRY_ADVICE_REBUILD — ask for the same operation again, which builds it afresh from what has moved on. Absent when nothing was said: a retry meets the same failure unless something changes first, which the reason names |
outcome_unknown | bool | The operation may still take effect: the failure is no proof that it did not happen. Undo, refund or repeat it only once its effect can be observed either way |
metadata | map<string, string> | The names and figures the reason comes with, decimals as strings; each reason below documents the ones it carries |
error_id | string | The id the node logged the whole failure under, for a failure it logged as a warning or an error: quote it when you report the failure. Absent for a refusal of the request, which says all there is to it |
gRPC carries it in the status details (the grpc-status-details-bin trailer): a google.rpc.Status with the status's own code and message, holding one Any of type type.googleapis.com/hydra_app.ErrorDetail, defined in error.proto of the /proto download. JSON-RPC carries it as the error's data, in the JSON mapping — enum values by name, fields in camelCase, absent fields left out:
{
"code": -32602,
"message": "Invalid request: unknown sending asset",
"data": {
"reason": "FAILURE_REASON_UNKNOWN_ASSET",
"metadata": { "side": "sending" }
}
}
The code and the message say what they always did, so a client that reads none of this is exactly as well off as before. Match on reason rather than on the message: messages are written for people, and their wording changes. A message is cut at 2 KiB, so the details fit beside it within the trailers' limit; a failure logged under an error_id is logged whole.
Python (grpcio with grpcio-status):
from grpc_status import rpc_status
import error_pb2 # generated from error.proto
def error_detail(error): # error: grpc.RpcError
status = rpc_status.from_call(error)
for packed in status.details if status else []:
if packed.Is(error_pb2.ErrorDetail.DESCRIPTOR):
detail = error_pb2.ErrorDetail()
packed.Unpack(detail)
return detail
return None
Rust (hydra-app-types): hydra_app_types::error_detail::error_detail(status.details()).
Reasons
Each reason is answered with the code below. A refusal of the venue's keeps the code the venue chose, which the venue does not fix for its prefixes; and a failure that may still take effect, or that a retry clears, is answered with DEADLINE_EXCEEDED, ABORTED or UNAVAILABLE whatever its reason. A client built before a reason was added reads a number it does not know: read the code and the message then.
| Reason | Code | Meaning |
|---|---|---|
FAILURE_REASON_MALFORMED_REQUEST | INVALID_ARGUMENT | A field the request needs is missing, does not parse, or holds a value, or a combination of values, the call does not take. |
FAILURE_REASON_UNSUPPORTED_NETWORK | INVALID_ARGUMENT | The request names a network this request does not take. |
FAILURE_REASON_UNKNOWN_ASSET | INVALID_ARGUMENT | The request names an asset this node does not know. Metadata: side (sending or receiving) on a swap. |
FAILURE_REASON_INVALID_AMOUNT | INVALID_ARGUMENT | An amount the call cannot take: zero or below where it needs a positive one, more than the protocol can represent, or finer than the asset divides into; or a request whose amounts are all zero. |
FAILURE_REASON_TOLERANCE_OUT_OF_RANGE | INVALID_ARGUMENT | A price-change tolerance outside [0, 1). |
FAILURE_REASON_SAME_ASSET_BOTH_SIDES | INVALID_ARGUMENT | A swap sending and receiving the same currency. |
FAILURE_REASON_FEES_EXCEED_AMOUNT | INVALID_ARGUMENT | A swap whose fees take all of its amount. |
FAILURE_REASON_LEASE_DURATION_REQUIRED | INVALID_ARGUMENT | A liquidity request that asks the provider for liquidity without a lease_duration. |
FAILURE_REASON_FEE_RATE_TOO_LOW | INVALID_ARGUMENT | A fee rate below what the network, or the transaction a fee bump replaces, requires. |
FAILURE_REASON_ALREADY_AT_RATE | INVALID_ARGUMENT | A fee bump at the rate the transaction already pays. |
FAILURE_REASON_INVALID_ADDRESS | INVALID_ARGUMENT | An address that is not one of the network's, or a peer address that does not parse. |
FAILURE_REASON_INVALID_INVOICE | INVALID_ARGUMENT | An invoice that does not decode, is for another network, or does not carry a valid signature of its issuer. |
FAILURE_REASON_INVALID_PREIMAGE | INVALID_ARGUMENT | A preimage that does not hash to the payment hash it is offered for. |
FAILURE_REASON_EXPIRY_TOO_SHORT | INVALID_ARGUMENT | An expiry too short for the payment: no route, and no peer, takes a payment that expires that soon. |
FAILURE_REASON_NOT_CONFIGURED | FAILED_PRECONDITION | This node runs without what the request needs: a component, or a setting of the network's node. Metadata: component (signer, node, pricing, authentication, liquidity, dex or referral) for a component; absent for a setting. |
FAILURE_REASON_PROTOCOL_NOT_SUPPORTED | FAILED_PRECONDITION | This build of Hydra App does not include the protocol the request needs. Metadata: protocol, the protocol's name. |
FAILURE_REASON_NETWORK_NOT_SERVED | FAILED_PRECONDITION | This node runs no node for the network the request needs. |
FAILURE_REASON_VENUE_NODE_UNKNOWN | FAILED_PRECONDITION | The venue's node on the network is not known to this node yet. |
FAILURE_REASON_PROVIDER_NODE_UNKNOWN | FAILED_PRECONDITION | The liquidity provider advertises no node on the network. |
FAILURE_REASON_INSUFFICIENT_BALANCE | FAILED_PRECONDITION | The balance the request spends is short of what it needs. |
FAILURE_REASON_INSUFFICIENT_NATIVE_BALANCE | FAILED_PRECONDITION | The native balance cannot pay the fee, or the resources, of the transaction the request needs. |
FAILURE_REASON_INSUFFICIENT_OFFCHAIN_BALANCE | FAILED_PRECONDITION | The off-chain balance cannot pay the fee this way; pay it on chain. |
FAILURE_REASON_INSUFFICIENT_FUNDS | FAILED_PRECONDITION | A fee bump the wallet cannot cover. |
FAILURE_REASON_WOULD_CREATE_DUST | FAILED_PRECONDITION | The operation would leave an output too small to spend, or one that cannot pay its own fee. |
FAILURE_REASON_NO_LIQUIDITY | FAILED_PRECONDITION | No liquidity can carry the swap. |
FAILURE_REASON_LEASE_TOO_LARGE | FAILED_PRECONDITION | The swap needs more leased liquidity than is available. |
FAILURE_REASON_CHANNEL_HOLDS_SWAP_PAYMENT | FAILED_PRECONDITION | The channel holds the inbound payment of a swap still in flight, which a dispute would hand back to the peer: wait for the swap to settle or fail. Metadata: channel_id, and swap_ids, the swaps' ids separated by commas. |
FAILURE_REASON_OPERATION_NOT_APPLICABLE | FAILED_PRECONDITION | The operation does not apply to the network's protocol; the message names the one that does. |
FAILURE_REASON_INSUFFICIENT_CHANNEL_BALANCE | FAILED_PRECONDITION | What this node's side of the channel, or of every channel the payment could take, can send is short of what the request needs. |
FAILURE_REASON_INSUFFICIENT_ALLOWANCE | FAILED_PRECONDITION | The allowance the wallet granted the spender is short of what the operation pulls. |
FAILURE_REASON_BELOW_CHANNEL_RESERVE | FAILED_PRECONDITION | The operation would take a side of the channel below the reserve it keeps while the channel is open; only a close pays the reserve out. |
FAILURE_REASON_AMOUNT_BELOW_MINIMUM | FAILED_PRECONDITION | An amount below the smallest the protocol, the channel or the peer takes. |
FAILURE_REASON_AMOUNT_ABOVE_MAXIMUM | FAILED_PRECONDITION | An amount above the largest the protocol, the channel or the peer takes. |
FAILURE_REASON_CHANNEL_NOT_READY | FAILED_PRECONDITION | The channel is not ready for the operation yet: it is opening, confirming, or recovering its state. |
FAILURE_REASON_CHANNEL_CLOSED | FAILED_PRECONDITION | The channel is closing or closed: the operation no longer applies to it. |
FAILURE_REASON_CHANNEL_UPDATE_IN_PROGRESS | FAILED_PRECONDITION | Another update of the channel is under way, an on-chain update, a splice or a close, and the operation can follow once it settles. |
FAILURE_REASON_CHANNEL_LIFETIME_NOT_OVER | FAILED_PRECONDITION | The channel was sold to stay open for a lifetime that has not run out yet. |
FAILURE_REASON_DISPUTE_PERIOD_NOT_OVER | FAILED_PRECONDITION | The dispute period of a channel closed on chain has not run out yet: what it settles can be redeemed once it has. |
FAILURE_REASON_PEER_REFUSED | FAILED_PRECONDITION | The peer refused the request as it stands, for its reserve, its balance, its policy or a deadline: build another request, or turn to another peer. |
FAILURE_REASON_PEER_BLOCKED | FAILED_PRECONDITION | This node refuses the peer. |
FAILURE_REASON_PEER_INCOMPATIBLE | FAILED_PRECONDITION | This node and the peer cannot agree on what the operation needs, its features, terms or limits: a change of configuration, or an upgrade, has to come first. |
FAILURE_REASON_PEER_MISBEHAVED | FAILED_PRECONDITION | The peer answered with what the protocol proves false, a signature that does not verify among them, and this node turned it away. |
FAILURE_REASON_NO_ROUTE | FAILED_PRECONDITION | No route reaches the recipient with the amount, the fees and the expiry the request asks for. |
FAILURE_REASON_INVOICE_EXPIRED | FAILED_PRECONDITION | The invoice has expired. |
FAILURE_REASON_TERMS_EXPIRED | FAILED_PRECONDITION | The terms the operation relies on, offered by an earlier answer, have ended: ask for them again. |
FAILURE_REASON_PAYMENT_ALREADY_SETTLED | FAILED_PRECONDITION | The payment has completed or failed already: nothing is left to settle. |
FAILURE_REASON_PAYMENTS_IN_FLIGHT | FAILED_PRECONDITION | Payments in flight on the channel keep the operation from going ahead: wait for them to settle, or reject them, first. |
FAILURE_REASON_NO_EFFECT | FAILED_PRECONDITION | The request had no effect: the state it would change already holds what it asks for, or moved on while it was under way. Read the state again before asking again. |
FAILURE_REASON_NOTHING_TO_SETTLE | FAILED_PRECONDITION | Nothing is left for the operation to settle: what it would settle is settled already, being settled, or empty. |
FAILURE_REASON_HTLC_TERMS_MISMATCH | FAILED_PRECONDITION | The HTLC does not carry the terms agreed for it, its script, amount, recipient, hash or expiry, so it can never be claimed as agreed. |
FAILURE_REASON_HTLC_NOT_EXPIRED | FAILED_PRECONDITION | The HTLC's timelock has not expired, so it cannot be refunded yet. |
FAILURE_REASON_HTLC_ALREADY_SETTLED | FAILED_PRECONDITION | The HTLC was claimed or refunded already. |
FAILURE_REASON_FEE_BUMP_REFUSED | FAILED_PRECONDITION | The transaction's fee cannot be raised. Metadata: refusal, why, as GetBumpEligibility names it (for example BUMP_REFUSAL_NO_LONGER_PENDING). |
FAILURE_REASON_ASSET_NOT_FOUND | NOT_FOUND | No asset with that id. |
FAILURE_REASON_SIMPLE_SWAP_NOT_FOUND | NOT_FOUND | No simple swap in progress with that id. |
FAILURE_REASON_CHANNEL_NOT_FOUND | NOT_FOUND | No channel with that id. |
FAILURE_REASON_PAYMENT_NOT_FOUND | NOT_FOUND | No payment with that id. |
FAILURE_REASON_TRANSACTION_NOT_FOUND | NOT_FOUND | No transaction with that id. |
FAILURE_REASON_LIQUIDITY_REQUEST_NOT_FOUND | NOT_FOUND | No liquidity request with that id. |
FAILURE_REASON_FIAT_PRICE_NOT_FOUND | NOT_FOUND | No fiat price for the asset. |
FAILURE_REASON_ASSET_CHANNEL_NOT_FOUND | NOT_FOUND | The channel holds no asset channel for that asset. |
FAILURE_REASON_HTLC_NOT_FOUND | NOT_FOUND | No HTLC with that id. |
FAILURE_REASON_CLIENT_REQUEST_ID_TAKEN | ALREADY_EXISTS | The client_request_id already names a different liquidity request. Metadata: payment_uuid, the request it names. |
FAILURE_REASON_PAYMENT_ALREADY_EXISTS | ALREADY_EXISTS | A payment with the same hash or id was made before. |
FAILURE_REASON_AUTHENTICATION_REJECTED | UNAUTHENTICATED | The authentication service refused this node's identity. Metadata: rejection (for example NOT_WHITELISTED). |
FAILURE_REASON_PRUNE_CANCELLING | UNAVAILABLE | An archive prune is winding down after a cancellation. |
FAILURE_REASON_TOO_MANY_SUBSCRIBERS | RESOURCE_EXHAUSTED | The event stream has as many subscribers as it takes. |
FAILURE_REASON_FEE_ESTIMATE_UNAVAILABLE | UNAVAILABLE | No fee estimate came within the time the call allows for one, and nothing was sent: send the request again. |
FAILURE_REASON_SERVER_DEADLINE | DEADLINE_EXCEEDED | The call outlived the server's deadline, and may still take effect: it carries outcome_unknown. |
FAILURE_REASON_PEER_NOT_CONNECTED | UNAVAILABLE | This node is not connected to the peer the operation needs, or its session with the peer is not established yet. |
FAILURE_REASON_PEER_UNREACHABLE | UNAVAILABLE | The peer could not be reached. |
FAILURE_REASON_PEER_UNRESPONSIVE | UNAVAILABLE | The peer did not answer in time. |
FAILURE_REASON_CHAIN_UNAVAILABLE | UNAVAILABLE | The chain's backend, an RPC node or an indexer, could not be reached or did not answer in time. |
FAILURE_REASON_NODE_SYNCING | UNAVAILABLE | The network's node is still catching up with the chain. |
FAILURE_REASON_CHANNEL_BUSY | UNAVAILABLE | The channel is busy with a step that clears within a round trip with the peer. |
FAILURE_REASON_CHANNEL_OUT_OF_SYNC | UNAVAILABLE | This node's record of the channel disagrees with the chain's, or with the peer's, and the two have to be brought back in line first. |
FAILURE_REASON_NODE_BUSY | UNAVAILABLE | The network's node could not carry out the operation in the time it allows, held up by other work. |
FAILURE_REASON_VENUE_BELOW_MINIMUM | the venue's | Below the market's minimum. Metadata: market, side, min_place, min_fill. |
FAILURE_REASON_VENUE_BELOW_BEST_FILL | the venue's | Below the smallest fill the best price allows. Metadata: market, min_fill, min_fill_unit (base or quote), price. |
FAILURE_REASON_VENUE_POST_ONLY_WOULD_CROSS | the venue's | A post-only order that would take. Metadata: market, price, best. |
FAILURE_REASON_VENUE_NOTHING_TO_TAKE | the venue's | A taking order with nothing to take at its price. Metadata: market, price, best. |
FAILURE_REASON_VENUE_FILL_OR_KILL_UNFILLED | the venue's | A fill-or-kill order the book cannot fill whole. Metadata: market, got, got_unit, fillable, fillable_unit (each base or quote), price. |
FAILURE_REASON_VENUE_SELF_TRADE_PREVENTED | the venue's | An order that would trade against the client's own. Metadata: market, resting_order_id. |
FAILURE_REASON_VENUE_STOPPING | the venue's | The venue is stopping. |
FAILURE_REASON_VENUE_ASSET_NOT_LISTED | the venue's | The currency is not listed. Metadata: currency. |
FAILURE_REASON_VENUE_MARKET_STATE | the venue's | The market does not take orders in its state. Metadata: market, state. |
FAILURE_REASON_VENUE_MAINTENANCE | the venue's | The venue is in maintenance. |
FAILURE_REASON_VENUE_HUB_UNREACHABLE | the venue's | The venue cannot reach the hub on the network. Metadata: network. |
FAILURE_REASON_VENUE_SUB_ECONOMIC_CLIENT_LIMIT | the venue's | The client's channels would hold more than closing them is worth. Metadata: force_close_usd, added_usd, allowance_usd. |
FAILURE_REASON_VENUE_ONCHAIN_LEG_FLOOR | the venue's | An on-chain leg below what the venue settles on chain. Metadata: currency, got, usd, floor. |
FAILURE_REASON_VENUE_AT_RISK_CAP | the venue's | The hub's capital at risk on the network would pass its cap. Metadata: network, projected, cap. |
FAILURE_REASON_VENUE_SUB_ECONOMIC_HTLC_CAP | the venue's | A payment above what a channel carries economically. Metadata: network, max. |
FAILURE_REASON_VENUE_SUB_ECONOMIC_VENUE_LIMIT | the venue's | The venue's standing in sub-economic payments is at its limit. Metadata: standing. |
FAILURE_REASON_VENUE_MATCHER_OVERLOADED | the venue's | The matcher is shedding load. Metadata: state, class. |
FAILURE_REASON_VENUE_CLIENT_ORDER_ID_TAKEN | the venue's | The client_order_id already names a different order. Metadata: order_id. |
FAILURE_REASON_VENUE_MAKER_UNAVAILABLE | the venue's | No maker of the match can settle now. |
FAILURE_REASON_VENUE_INSUFFICIENT_CAPACITY | the venue's | A channel leg lacks the capacity the order needs. Metadata: currency, leg (sending or receiving), required, available. |
FAILURE_REASON_REFERRAL_NOT_ELIGIBLE | PERMISSION_DENIED | This node is not eligible for invites. |
FAILURE_REASON_REFERRAL_EPOCH_GATED | FAILED_PRECONDITION | Invites are not open in this epoch. |
FAILURE_REASON_REFERRAL_CODE_EXPIRED | FAILED_PRECONDITION | The code has expired. |
FAILURE_REASON_REFERRAL_SELF_REDEEM | FAILED_PRECONDITION | A node cannot redeem its own code. |
FAILURE_REASON_REFERRAL_QUOTA_EXHAUSTED | RESOURCE_EXHAUSTED | The quota of codes is used up. |
FAILURE_REASON_REFERRAL_CODE_NOT_FOUND | NOT_FOUND | No such code. |
FAILURE_REASON_REFERRAL_CODE_ALREADY_REDEEMED | ALREADY_EXISTS | The code was redeemed already. |
FAILURE_REASON_REFERRAL_ELIGIBILITY_EXISTS | ALREADY_EXISTS | This node is eligible already. |
FAILURE_REASON_CALL_NOT_SUPPORTED | UNIMPLEMENTED | This build of Hydra App does not offer the call on the network's protocol: support for the protocol is not compiled in, or the protocol has no such operation. |
Common Status Codes
OK (0)
Description: Success - no error
When it occurs: Successful operation
Action: None required
CANCELLED (1)
Description: Operation was cancelled
When it occurs:
- Client cancelled request
- Stream was closed
Action: Retry if needed
UNKNOWN (2)
Description: Unknown error
When it occurs:
- Unexpected server error
- Unhandled exception
Action: Check request parameters, contact support if persists
INVALID_ARGUMENT (3)
Description: Client specified an invalid argument
Common causes:
- Invalid asset ID format
- Invalid network parameters
- Malformed request data
- Out-of-range values
Examples:
// Error: invalid asset id
{
"code": 3,
"message": "invalid asset id"
}
// Error: invalid network
{
"code": 3,
"message": "unsupported network protocol"
}
// Error: invalid amount
{
"code": 3,
"message": "amount must be positive"
}
Action:
- Verify asset ID format (zero-padded hash for native assets, contract address for ERC-20s — see
asset.GetNativeAsset/asset.GetAssets) - Check
Networkshape:{ protocol, id }only — notchain_id/name - Validate all numeric values (
DecimalStringis human-readable, not base units) - Ensure required fields and one-of variants are present (e.g. exactly one
fee_paymenton Lease requests)
DEADLINE_EXCEEDED (4)
Description: The request ran out of time, and the operation may still have completed.
Don't read it as a failure. A read can simply be retried; for a write — a payment, an order, a channel operation — check whether it took effect before sending it again. A request that ran out of time without doing anything answers UNAVAILABLE instead, which is safe to retry.
The server's request deadline is 30 s per call. (2026-10-14) Over JSON-RPC it bounds each call on its own, wherever the call arrives: every call of a batch has its own 30 s, over HTTP or over a WebSocket. A call cut off there answers its own JSON-RPC error -32002 — over HTTP inside an ordinary 200 response, never an empty-bodied HTTP 504 — while the batch's other calls answer as usual. See the request deadline on JSON-RPC.
Three LiquidityService RPCs are exempt from that deadline — the deprecated RequestChannelLiquidity, RequestChannelRelease and RequestChannelLeaseExtension, over gRPC and (2026-10-14) over JSON-RPC alike. Each waits until the liquidity service has carried the request out, which on the on-chain fee rail means waiting through the fee transfer's confirmations: past 30 s, minutes on Ethereum. Set a client deadline of minutes on those three, or you will abandon a request whose fee has already been paid. (2026-10-14) A JSON-RPC subscription call is exempt too: it answers once its subscription is accepted.
(2026-10-14) Their replacements — SubmitChannelLiquidityRequest, SubmitChannelReleaseRequest and SubmitChannelLeaseExtensionRequest — are not exempt. They answer once your node has settled its part of the fee, normally within seconds, and the request is then followed to its end; see Submitting and following a request.
Action:
- Retry with exponential backoff — only if the RPC is idempotent; see Idempotency & Retry Safety
- Give chain-bound calls a client deadline measured in minutes, not seconds
- For anything you want to watch rather than wait on, use the streaming endpoints
A timeout is not a rollback. The operation may have completed server-side after your deadline fired. For a write, verify before retrying.
Recovering a
Submit*call that was cut off. (2026-10-14) The request may already have been made: the submission runs on in your node after its caller is gone. Send the same request again under the sameclient_request_id— it answers with the request already made, waiting for the first call if that is still running, and makes the request only if none was made.GetLiquidityRequestby the id reads it without submitting. Without an id, find the request onSubscribeLiquidityRequestsor inGetFeePaymentsbefore you submit again. Seeclient_request_id.
NOT_FOUND (5)
Description: Requested resource not found
Common causes:
- Transaction doesn't exist
- Order not found
- Channel doesn't exist
- Payment not found
Examples:
// Error: transaction not found
{
"code": 5,
"message": "transaction not found: abc123..."
}
// Error: order not found
{
"code": 5,
"message": "order not found: order_xyz789"
}
Action:
- Verify resource ID is correct
- Check that resource exists on the specified network
- Ensure resource hasn't been deleted
ALREADY_EXISTS (6)
Description: Resource already exists
When it occurs:
- Attempting to create duplicate resource
- Market already initialized
- Channel already opened
orderbook.CreateOrderof a different order under aclient_order_idthat already names one (prefixclient_order_id_taken)- (2026-10-14) A
liquidity.Submit*call of a different request under aclient_request_idthat already names one (client_request_id_taken: payment_uuid=…, naming that request — see Liquidity Service)
Examples:
{
"code": 6,
"message": "market already initialized"
}
Action:
- Check if resource already exists
- Use update/modify operations instead of create
PERMISSION_DENIED (7)
Description: Caller doesn't have permission
When it occurs:
- Authentication failure
- Insufficient permissions
- Attempting to access another user's resource
Action:
- Verify authentication credentials
- Check API key permissions
- Ensure you own the resource
RESOURCE_EXHAUSTED (8)
Description: A limit on how much of something you may use at once is reached
Common causes:
- A stream already has as many subscribers as it takes (
TOO_MANY_SUBSCRIBERS) - Your referral quota is used up (
REFERRAL_QUOTA_EXHAUSTED) - The hub or the liquidity service refuses a request for a quota of its own — a rate limit — and Hydra App passes its code on
A balance, a channel or a liquidity pool too small for the request is not this code: it is FAILED_PRECONDITION (9).
Examples:
// Error: a stream with no room for another subscriber
{
"code": 8,
"message": "too many subscribers"
}
Action:
- Close a subscription you no longer read before opening another
- Back off and retry later: a quota frees up as earlier requests complete
FAILED_PRECONDITION (9)
Description: Operation rejected due to system state
Common causes:
- Channel not active yet
- Market not initialized
- Asset not supported
- Duration out of allowed range
- Not enough balance on chain or in a channel (
INSUFFICIENT_BALANCE,INSUFFICIENT_NATIVE_BALANCE,INSUFFICIENT_CHANNEL_BALANCE), and any refusal whose wording says something is insufficient - No liquidity for the request (
NO_LIQUIDITY), or a lease larger than what is available (LEASE_TOO_LARGE)
Examples:
// Error: channel not ready
{
"code": 9,
"message": "channel not active for sending"
}
// Error: insufficient balance
{
"code": 9,
"message": "insufficient onchain balance"
}
// Error: market not initialized
{
"code": 9,
"message": "market not initialized for this pair"
}
// Error: duration invalid
{
"code": 9,
"message": "lease duration below minimum"
}
Action:
- Wait for prerequisite conditions
- Initialize required resources first
- Check state before operations
- Verify parameter ranges
- Add funds, or try a smaller amount, where a balance or liquidity falls short
ABORTED (10)
Description: Operation was aborted
When it occurs:
- Concurrent modification conflict
- Transaction conflict
- Order already cancelled
Action: Retry operation
OUT_OF_RANGE (11)
Description: Operation outside valid range
Common causes:
- Amount too large or too small
- Fee rate invalid
- Duration out of range
Action:
- Check minimum/maximum values
- Adjust parameters to valid range
UNIMPLEMENTED (12)
Description: Operation not implemented
When it occurs:
- Feature not yet available
- Network not supported
A deprecated method is not this code: it keeps answering as before until it is removed.
Action:
- Check API documentation for supported features
- Use alternative endpoint
INTERNAL (13)
Description: Internal server error
When it occurs:
- Database error
- Backend service failure
- Unexpected server condition
Action:
- Retry with exponential backoff
- Contact support if persists
UNAVAILABLE (14)
Description: Service temporarily unavailable
Common causes:
- Service maintenance
- Network issues
- Backend overload
- A wait inside Hydra App ran out before the operation was sent — for a peer, a chain provider, a lock. The message may say "timeout" or "deadline"; nothing took effect, and the request may be sent again
Action:
- Retry with exponential backoff
- Check status page
- Wait and retry
DATA_LOSS (15)
Description: Unrecoverable data loss
When it occurs: Rare - serious server issue
Action: Contact support immediately
UNAUTHENTICATED (16)
Description: Request lacks valid authentication
When it occurs:
- Missing authentication
- Invalid credentials
- Expired session
Action:
- Provide authentication credentials
- Refresh authentication token
- Check API key is valid
Error Handling Best Practices
1. Implement Retry Logic — for idempotent RPCs only
⚠️ Use this helper only for
Get*,Estimate*, and stream-reconnect calls. WrappingSendTransaction,OpenChannel,CreateOrder, etc. in a blind retry can cause double-execution. See Idempotency & Retry Safety for the full rules.
async function withRetry<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation()
} catch (error: any) {
const code = error.code
// Don't retry client errors (INVALID_ARGUMENT, NOT_FOUND, PERMISSION_DENIED,
// OUT_OF_RANGE, UNIMPLEMENTED, UNAUTHENTICATED, FAILED_PRECONDITION)
if ([3, 5, 7, 9, 11, 12, 16].includes(code)) {
throw error
}
// Retry on transient errors (DEADLINE_EXCEEDED, UNAVAILABLE) and INTERNAL
if ([4, 13, 14].includes(code)) {
const delay = baseDelay * Math.pow(2, i) + Math.floor(Math.random() * 500)
console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`)
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
throw error
}
}
throw new Error('Max retries exceeded')
}
// ✅ Safe — Get* is idempotent
const balance = await withRetry(() => walletClient.getBalance(request, {}))
// ❌ DANGEROUS — SendTransaction is NOT idempotent.
// A DEADLINE_EXCEEDED retry can broadcast twice.
// Use the verify-before-retry pattern from the Idempotency section.
// const tx = await withRetry(() => clientClient.sendTransaction(request, {}))
2. Graceful Error Handling
async function safeGetBalance(
client: WalletServiceClient,
network: Network,
assetId: string
): Promise<Balance | null> {
try {
const request = new GetBalanceRequest()
request.setNetwork(network)
request.setAssetId(assetId)
const response = await client.getBalance(request, {})
return response.getBalance()
} catch (error) {
switch (error.code) {
case 3:
console.error('Invalid asset ID:', assetId)
return null
case 5:
console.error('Asset not found:', assetId)
return null
case 14:
console.warn('Service unavailable, retrying...')
// Implement retry logic
return null
default:
console.error('Unexpected error:', error)
throw error
}
}
}
3. Check Preconditions
async function sendPaymentSafely(
walletClient: WalletServiceClient,
nodeClient: NodeServiceClient,
network: Network,
assetId: string,
amount: string
) {
// Check balance first
const balanceReq = new GetBalanceRequest()
balanceReq.setNetwork(network)
balanceReq.setAssetId(assetId)
const balanceResp = await walletClient.getBalance(balanceReq, {})
// Size a single payment off max_sendable, not free_local — a per-payment
// ceiling can bind below the balance.
const available = balanceResp.getBalance()?.getOffchain()?.getMaxSendable()?.getValue()
// Amounts are decimal strings in whole units — compare as decimals, not BigInt.
if (new Big(available || '0').lt(new Big(amount))) {
throw new Error('Insufficient balance')
}
// Proceed with payment
const paymentReq = new SendPaymentRequest()
// ... configure payment
return await nodeClient.sendPayment(paymentReq, {})
}
4. User-Friendly Messages
function getUserFriendlyError(error: any): string {
switch (error.code) {
case 3:
return 'Invalid input. Please check your entries.'
case 5:
return 'Resource not found. Please verify the ID.'
case 8:
return 'Too many requests at once. Please try again later.'
case 9:
if (error.message.includes('insufficient')) {
return 'Insufficient funds. Please add more, or try a smaller amount.'
}
if (error.message.includes('liquidity')) {
return 'Not enough liquidity available. Try a smaller amount.'
}
return 'Operation cannot be completed right now. Please try again later.'
case 14:
return 'Service temporarily unavailable. Please try again in a moment.'
default:
return 'An unexpected error occurred. Please try again.'
}
}
// Usage in UI
try {
await client.someOperation(request, {})
} catch (error) {
alert(getUserFriendlyError(error))
}
Service-Specific Errors
Wallet Service
| Error | Cause | Solution |
|---|---|---|
INVALID_ARGUMENT: invalid asset id | Wrong asset ID format | Check asset ID for network type |
NOT_FOUND: transaction not found | Transaction doesn't exist | Verify txid is correct |
Orderbook Service
| Error | Cause | Solution |
|---|---|---|
FAILED_PRECONDITION: no liquidity | Empty orderbook | Wait or provide liquidity |
FAILED_PRECONDITION: market not initialized | Market doesn't exist | Call InitMarket first |
INVALID_ARGUMENT: amount below minimum | Amount too small | Increase to min_base_amount |
INVALID_ARGUMENT: ... out of range | A price, an amount, or an amount taken at its price, past what the market can hold: the book counts both in whole units of the market's precision, up to 2⁶³−1 | Send a price and amount within the market's range |
-32602 unknown field 'addLiquidity' | Used the removed add_liquidity order variant (removed 2026-06-11) | Provide liquidity with limit_orders instead — see Orderbook → Create Order |
INVALID_ARGUMENT: on-chain settlement not allowed on maker order | Set settlement/route_filter on a resting maker (limit) order | On-chain settlement is taker-only; omit it on maker orders |
Swap Service
| Error | Cause | Solution |
|---|---|---|
FAILED_PRECONDITION: insufficient balance | Not enough offchain funds for a plain Swap | Use SimpleSwap, which sets the channels up for you |
FAILED_PRECONDITION: price exceeded tolerance | Price moved past price_change_tolerance between estimate and execute | Re-estimate and retry; widen the tolerance on volatile pairs |
Most simple-swap refusals are not errors at all.
EstimateSimpleSwapsucceeds and returns aSimpleSwapEstimatevariant —no_liquidity,fees_higher_than_amount,lease_too_big,insufficient_sending_balance,insufficient_native_balance. Branch on the one-of; aswitchthat only handles the three success arms will misreport every one of these.
Node Service
| Error | Cause | Solution |
|---|---|---|
FAILED_PRECONDITION: channel not active | Channel still opening / confirming | Wait for the Active event from SubscribeNodeEvents |
NOT_FOUND: channel not found | Invalid channel_id | Verify with watchOnlyNode.GetChannels |
FAILED_PRECONDITION: insufficient capacity | Channel too small | Deposit more funds via node.DepositChannel or liquidity.SubmitChannelLiquidityRequest (deposit op) |
INVALID_ARGUMENT: peer not in zero-conf whitelist | Tried zero-conf with non-whitelisted peer | Add peer via node.AddPeerToZeroConfWhitelist first |
INVALID_ARGUMENT from node.UpdateChannel, contributions cancel out | The chain refuses an update that leaves the channel holding what it already held | Contributions that cancel are a payment, not an update — send it as one. See Update Channel |
INVALID_ARGUMENT from node.UpdateChannel on peer_contribution | Used deposit: { all: {} } on the peer's side, where All would resolve against a wallet your node cannot see | Name an exact amount for a peer deposit |
FAILED_PRECONDITION: funding allowance exceeded | Peer-initiated deposit/payment exceeds set allowance | Raise via node.IncreaseFundingAllowance |
Placement rejections (Orderbook)
Added 2026-08-13.
When the hub refuses an order, it encodes the reason in a stable, parseable message so an SDK can react without scraping prose:
<prefix>: key=value key=value — human tail
Match on the prefix and the key=value tokens only — never on the human tail, which is free text and may change. The gRPC status code is deliberately not part of the contract; the prefix is.
| Prefix | Keys | Meaning |
|---|---|---|
below_minimum | market, side, got, and exactly one of min_place / min_fill | Order size is under a placement minimum. The key present names the larger, binding one: min_place = the hub's USD placement floor at live prices; min_fill = the structural minimum. |
below_best_fill | side, got, min_fill, price, market | Strict price priority: the best order this one would have to take first rests at price, and its smallest fill, min_fill, is more than the order has (got; both in the unit side names). A taker takes nothing past that order, and a limit order would rest across it. Nothing was created: re-place at least min_fill, or at a price that does not reach price — see Price priority. Answered with FAILED_PRECONDITION |
asset_not_listed | currency | That currency isn't listed on the hub |
market_state | market, state | The market does not take this order in its current state: post_only (refuses market and swap orders, and limit orders that only take — IOC, FOK), cancel_only or frozen |
post_only_would_cross | price, best, market | A post-only limit order at price — or any limit order on a MARKET_STATE_POST_ONLY market — would have taken an order on offer, the best at best. Nothing was created. Re-price so it does not cross. Answered with FAILED_PRECONDITION |
nothing_to_take | market, and price / best when there is one | An order that only takes — a TIME_IN_FORCE_IOC limit order, or a market order — found nothing it may take within price (absent for a market order without limit_price). best is the best price an order it may take rests at on the other side, absent when there is none. Nothing was created. Answered with FAILED_PRECONDITION. A market order over an empty side used to be answered with an unprefixed INVALID_ARGUMENT |
fill_or_kill_unfilled | side, got, fillable, price, market | A TIME_IN_FORCE_FOK limit order for got could take only fillable at price or better (both in the unit side names). Nothing was created, and the book was left as it was. Answered with FAILED_PRECONDITION |
self_trade_prevented | resting_order_id, market | The order's self_trade_prevention stopped it at resting_order_id, a resting order of yours, before it filled anything. Nothing was created. Answered with FAILED_PRECONDITION |
venue_stopping | — | The hub is restarting and turns no new match into a swap until it is back: the order ended with nothing filled. Not a fault of the order: send it again once the hub is back. Answered with UNAVAILABLE |
sub_economic_client_limit | force_close_usd, added_usd, allowance_usd | (2026-09-16) Force-closing this client's in-flight sub-economic swaps already costs force_close_usd, and this match would add added_usd on top of an allowance of allowance_usd |
sub_economic_htlc_cap | network, node, current, addition, max | The peer already holds its maximum concurrent sub-economic HTLC legs |
sub_economic_venue_limit | force_close_usd, added_usd, admissible_usd, standing | (2026-09-16) The same figure venue-wide has no room for this match. admissible_usd rises with standing (settled volume, [0, 1]) |
onchain_leg_floor | currency, got, usd, floor | An on-chain leg of got in currency, worth usd, is below the floor the venue settles on chain |
at_risk_cap | network, projected, cap | The hub's capital at risk on network would reach projected, past its cap: try a smaller order, or later |
venue_maintenance | — | The venue is in maintenance and takes no order: send it again once maintenance ends |
hub_unreachable | network | The venue cannot reach the hub on network right now |
matcher_overloaded | state, class | The matcher is shedding load |
client_order_id_taken | order_id | The client_order_id already names a different order of yours — live, or ended within the hub's retention period (24 hours by default). order_id is that order: resend it unchanged to get it back, or place this one under a new id. Hydra App answers it with ALREADY_EXISTS |
maker_unavailable | — | None of the makers the order matched can settle with it right now, so no swap was made and nothing of the order filled: every maker was skipped as the match's swaps were created — the order may already have been announced on the event stream, and ended there — or, (2026-10-14) found before matching, the hub cannot pay one of them its leg, and nothing was created. Names no maker. Not a fault of the order: retry later; until that maker's channel frees, a retry can meet the same maker. Answered with FAILED_PRECONDITION |
insufficient_capacity | currency, leg, required, available | (2026-10-14) The order needs required of your leg capacity (sending or receiving) in currency, more than the available the refusing check found left for it. Nothing was created: size the order to fit. At placement available is your GetOrderbookBalances sending / receiving, and a limit order's required on the receiving leg is what it receives in full at the market's max_maker_discount, so a maker rebate adds to it — see Sizing an order to fit your balance. An order that would trade is also refused with leg=receiving when the hub cannot forward its own fills now (available is then what the hub can forward to you), and a market order whose match took more than its estimate with required the excess. The human tail still reads Not enough <leg> capacity to place this order, …, the sentence this refusal carried before it had a prefix; match the prefix. Answered with FAILED_PRECONDITION |
Anything not matching this closed set — including every message from a pre-2026-08-13 hub — is unclassified and should be treated as an opaque error, exactly as before.
(2026-10-14) Hydra App answers an unclassified refusal with the hub's own code — INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, FAILED_PRECONDITION or RESOURCE_EXHAUSTED — unless its wording names a condition that has a code of its own: a node that is not connected to the hub is still UNAVAILABLE. Over JSON-RPC that is -32602, -32004, -32005, -32010 or -32003. Before, such a refusal reached you as INTERNAL (-32603).
The three sub-economic prefixes were renamed on 2026-09-16, and their keys with them. They were
below_floor_budgetandbelow_floor_countwhen this page was first written. A parser matching the old names now falls through to unclassified — which is safe (the order is still refused, and the refusal is still retryable) but loses the numbers that say what to do about it. The amounts are force-closure costs, not trade notionals: see Get Sub-Economic Policy for what they are measured in and how to predict a refusal instead of discovering it.All three are retryable and none of them invalidates cached
MarketInfo.sub_economic_client_limitandsub_economic_venue_limitclear as your in-flight small swaps settle;sub_economic_htlc_capclears as their HTLCs resolve.
Only
below_minimuminvalidates your cachedMarketInfo. It means the hub's live minimums moved past your cached view, so refetchGetMarketInfoand resize from themin_place=in the rejection — that value is authoritative, themin_place_*snapshot inMarketInfois only advisory.below_best_filldoes not: no minimum moved, the order met a resting order it is too small for.
market_statedoes not qualify: the market'sstateis what changed, and themarket_infoevent on the market stream carries the new one the moment it changes — no refetch needed.
HTLC & Preimage Service
| Error | Cause | Solution |
|---|---|---|
-32601 method not found: node_registerPreimage | Called the removed node.RegisterPreimage (removed 2026-06-11) | Call preimage.SettlePreimage — same request fields |
FAILED_PRECONDITION from htlc.CreateHtlcSettlementTx / DeriveHtlcAddress / VerifyHtlcByLockTxid | Called a UTXO-model-only RPC on an account-model (EVM / Tron) network | These pre-sign / fund-by-address flows apply to UTXO protocols only; account-model HTLCs settle permissionlessly |
FAILED_PRECONDITION: on-chain HTLCs unavailable | Network has no htlc_factory_address (EVM / Tron) configured | Set htlc_factory_address for that network in config.yaml, or settle via channel |
INVALID_ARGUMENT: lock_type (rejected/unsupported) | Passed a lock_type a protocol with a single canonical lock construction doesn't accept, or an HtlcExpectation.lock_type the lock doesn't match | Leave lock_type empty for the node default, or pin the type the counterparty actually used |
FAILED_PRECONDITION from preimage.SettleHtlcPreimage | Called it on a network with no on-chain HTLC facet | Use preimage.SettlePreimage alone there — that network settles through channels only |
On-chain HTLCs not swept after preimage.SettlePreimage | Since 2026-08-01 SettlePreimage covers channel legs only | Also call preimage.SettleHtlcPreimage; on a network with both facets you drive both |
SettleHtlcPreimage returns an empty txids list | The preimage unlocks no Locked HTLC on that network (or they were already claimed) | Not an error — it is idempotent and a no-op is a success. A partial list is also normal: a claim that can't be built is skipped, not failed |
Liquidity Service (Lease API)
(2026-10-14) A request the liquidity service refuses with INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, FAILED_PRECONDITION or RESOURCE_EXHAUSTED is answered with that code, the service's words in the message — unless those words name a condition that clears, such as a peer that is not connected, which keeps the code they pick. Before, Hydra App answered most of these refusals with INTERNAL.
| Error | Cause | Solution |
|---|---|---|
INVALID_ARGUMENT: lease_duration_seconds required | server_amount > 0 without a duration | Set lease_duration_seconds, or set server_amount = "0" |
INVALID_ARGUMENT: dual_fund_fee_payment not allowed | Used dual_fund_fee_payment or sponsored_deposit_fee_payment outside a channel liquidity request, or sponsored_withdrawal_fee_payment outside a release request | Each sponsored method belongs to one operation — see the per-operation table |
ALREADY_EXISTS: client_request_id_taken: payment_uuid=… | (2026-10-14) A Submit* call of a different request under a client_request_id that already names one. payment_uuid is the request the id names | On a retry, resend the first request exactly — a re-estimated quote_id makes it a different request; otherwise use a new id. See client_request_id |
INVALID_ARGUMENT: client_request_id must be 1 to 64 characters, got N | (2026-10-14) An empty or over-long client_request_id | Send 1 to 64 characters, or no id |
INVALID_ARGUMENT: client_request_id is answered by Submit…, not by this deprecated RPC | (2026-10-14) A client_request_id on a deprecated Request* RPC, which refuses it; the message names the matching Submit* RPC | Call that Submit* RPC |
NOT_FOUND: no such liquidity request | (2026-10-14) GetLiquidityRequest for a request your node does not hold on that network | Check the network and the id. Right after a Submit* call was cut off, submit again under the same id instead — the first call may not have reached the service yet |
DEADLINE_EXCEEDED on a Submit* call | (2026-10-14) The call outlived the 30 s request deadline; the request may already have been made | Submit again under the same client_request_id, or read the request back by it — see above |
| Channel holds a balance but no gas, and every fee method is refused | On-chain needs a funded wallet; off-chain needs the very balance the release is about to take away | (2026-09-11) Use sponsored_withdrawal_fee_payment — the only method that works from that state |
FAILED_PRECONDITION naming a quote_id | (2026-09-11) The quote expired, or the request no longer matches the one it was issued for | Re-run the matching Estimate*Fee, show the user the new fee, and send the fresh quote_id — see Quotes |
-32602 unknown field 'assetIds' on a withdraw release | ChannelReleaseOperation.Withdraw replaced asset_ids[] with asset_amounts{} (2026-07-23) | Send a map of asset_id → { server_amount?, client_amount? }; at least one side per asset — see ChannelReleaseOperation |
INVALID_ARGUMENT on a withdraw with no channel_id | channel_id may be absent only on EstimateRequestChannelReleaseFee, and every side must then be an exact amount | Supply channel_id for the real operation |
| A release quote comes back for a channel you don't own, or the exit falls to the local rail | (2026-09-13) Sent channel_id: "" where the field should be absent. The service's ownership check rejects an empty string | Omit the field on an estimate — see ChannelReleaseOperation |
INVALID_ARGUMENT: exactly one fee_payment required | None or multiple fee_payment variants set | Set exactly one |
RESOURCE_EXHAUSTED: Hub capital committed to this client would exceed the per-client limit… | The request would take your leased capital past the provider's per-client limit | Lease less, or let a lease lapse first |
RESOURCE_EXHAUSTED naming a sponsored rail's budget | The provider has absorbed as much unrecovered gas, or runs as many sponsored rounds, as it allows for now | Try again later, or pay the fee on another rail |
FAILED_PRECONDITION: A fee is not accepted in … on the … rail | (2026-10-14) The fee's asset or rail is not among those the provider accepts | Pay in an asset, and on a rail, listed in GetLiquidityServiceInfo's fee_payment_assets |
FAILED_PRECONDITION: Operation #N: asset … of channel … has an on-chain operation awaiting confirmation | (2026-10-14) A deposit, withdrawal or close of an asset channel whose last on-chain operation has not confirmed | Wait until your node shows the asset channel is_updatable again, then ask again — do not retry on a timer |
FAILED_PRECONDITION: Operation #N: asset … of channel … is not updatable | (2026-10-14) A deposit, withdrawal or close of an asset channel the hub's node cannot update now: an update of it is under way, or your node is not connected to the hub | Wait until your node shows the asset channel is_updatable again — reconnect to the hub if your node is not connected — then ask again; do not retry on a timer |
FAILED_PRECONDITION: channel does not exist | Bad channel_id on Release / Lease Extension | Verify with watchOnlyNode.GetChannel |
FAILED_PRECONDITION: … the hub keeps asset … of channel … open until … | (2026-10-14) A withdraw or cooperative_close of an asset channel the hub opened on demand, before the hub's keep_open_until, the time the refusal names. Refused by the estimate and the request alike, before any fee is paid | Close the channel from your own node, or release it once that time has passed — see Rental |
Watch-Only Node Service
| Error | Cause | Solution |
|---|---|---|
NOT_FOUND: channel not found | Bad channel_id | Use GetChannels to enumerate |
NOT_FOUND: payment not found | Bad payment_id | Use GetPayments / GetPaymentsByHash to look up |
Client Service
| Error | Cause | Solution |
|---|---|---|
INVALID_ARGUMENT: invalid amount | Amount oneof unset, or DecimalString not a number | Set exact or all; ensure value parses |
FAILED_PRECONDITION: insufficient onchain balance | Wallet under-funded for the send + fee | Wait for incoming deposits or reduce amount |
FAILED_PRECONDITION: token allowance too low | EVM token send needs higher allowance | Call client.SetTokenAllowance first, or submit a signed token permit |
FAILED_PRECONDITION on a token operation with a funded token balance | The native asset cannot cover the gas — a separate resource from the token being moved | Fund the native asset, or use a rail that pays it for you: SponsoredDepositFeePayment / DEPOSIT_RAIL_LIQUIDITY_SERVICE |
| The permit submission reverted on-chain | The deadline passed, the signature does not match owner, or the token already consumed that nonce | Re-read blockchain.GetTokenPermitTerms, re-sign, and submit promptly. The fee is spent either way |
Pricing Service
| Error | Cause | Solution |
|---|---|---|
| No price returned | The oracle does not list that deployment or its symbol, or the price it holds is stale | The response field price is simply absent — a success with an empty optional, not an error. Treat it as "unknown" and render without a fiat figure |
Idempotency & Retry Safety
A bot must know which RPCs are safe to retry blindly after a DEADLINE_EXCEEDED or transient UNAVAILABLE.
Safe to retry (read-only, idempotent)
| RPC family | Safe to retry? |
|---|---|
All Get* queries on every service | ✅ Always |
Estimate* RPCs (e.g. EstimateOpenChannelFee, EstimateRequestChannelLiquidityFee) | ✅ Always |
Subscribe* (re-establishing after a drop) | ✅ Always — reconnect with backoff |
preimage.SettlePreimage | ✅ Idempotent — settling an already-settled preimage is a no-op |
preimage.SettleHtlcPreimage | ✅ Idempotent — an HTLC already claimed is no longer Locked, so a retry simply claims nothing (empty txids) |
node.AddPeerToBlacklist / RemovePeerFromBlacklist | ✅ Idempotent |
htlc.UnwatchHtlc | ✅ Idempotent |
liquidity.SubmitChannelLiquidityRequest, SubmitChannelReleaseRequest, SubmitChannelLeaseExtensionRequest — sent again unchanged under the same client_request_id | ✅ (2026-10-14) Answers with the request already made, as it stands now, and pays nothing again. Without an id they are not idempotent — see below |
Retry only after checking server state first
These RPCs are not idempotent. After a DEADLINE_EXCEEDED you may have triggered the action without seeing the response. Verify before retrying:
| RPC | How to verify before retry |
|---|---|
client.SendTransaction, client.SendTokenTransaction, client.BumpTransaction | Query wallet.GetTransactions for a recent tx with the same recipient / amount |
client.SetTokenAllowance | Query blockchain.GetTokenAllowance for the new value |
client.FinalizeAndBroadcastTransaction | Query wallet.GetTransaction for the txid |
node.OpenChannel, node.DepositChannel, node.WithdrawChannel, node.UpdateChannel, node.CloseChannel, node.ForceCloseChannel, node.RedeemClosedChannel | Watch SubscribeNodeEvents — events for the channel will fire if the operation went through |
node.SendChannelPayment, node.SendPayment, node.PayInvoice, node.PayEmptyInvoice | Query watchOnlyNode.GetPaymentsByHash (you must know the payment hash) |
liquidity.RequestChannelLiquidity, liquidity.RequestChannelRelease, liquidity.RequestChannelLeaseExtension (deprecated), and the liquidity.Submit* RPCs sent without a client_request_id | (2026-10-14) Your node records every request these make. Find it on liquidity.SubscribeLiquidityRequests, whose opening snapshot holds every request still in progress, or in liquidity.GetFeePayments, then follow it by its payment_uuid with liquidity.GetLiquidityRequest. Better: use the Submit* RPCs under a client_request_id, and retry under it |
orderbook.CreateOrder | Don't — set client_order_id instead and the retry is idempotent by construction. Without one, query orderbook.GetAllOwnOrders for the same (base, quote, side, amount, price) shape, which is not perfectly unique |
orderbook.CancelOrder | Query orderbook.GetOrder — if status is cancelled, the request succeeded |
swap.Swap, swap.SimpleSwap | swap.SubscribeSimpleSwaps will emit progress; otherwise orderbook.GetAllOwnOrders |
Never auto-retry
| Status | Why |
|---|---|
INVALID_ARGUMENT | Your request is wrong — fix the code, don't retry |
FAILED_PRECONDITION | State doesn't allow this — wait for state change, then re-evaluate (don't blindly retry) |
PERMISSION_DENIED / UNAUTHENTICATED | Auth issue — re-authenticate first |
OUT_OF_RANGE | Numerical input outside permitted range — fix |
UNIMPLEMENTED | RPC doesn't exist on this server version |
Application-level idempotency keys
orderbook.CreateOrder takes an idempotency token. Set client_order_id (your own string, max 64 chars) and it names that one order, while the order lives and for a retention period after it ends (24 hours by default). A retry of the same order under it gets the original answer — its order_id, and what its placement released — even when the order has ended meanwhile, instead of placing a second order; a different order under it is refused with client_order_id_taken. Read an open order back with GetOrderByClientId. Use it on every retry-prone order path — it is strictly better than any verify-then-retry dance.
(2026-10-14) So do the three liquidity.Submit* RPCs. Set client_request_id (your own string, 1 to 64 characters) and it names that one request on that network, across restarts of your node. The same request sent again under it gets the request already made, as it stands now, and pays nothing; a different request under it is refused with ALREADY_EXISTS and client_request_id_taken: payment_uuid=…, naming the request the id holds. A request the service refused leaves the id free. Read a request back by it with GetLiquidityRequest. See client_request_id.
Everything else needs an action ID at the application level:
- Generate a UUID for each high-level action (e.g. "place 0.1 BTC sell at $67k").
- Persist
(action_id, status: pending|done|failed)on disk before calling the RPC. - After the RPC returns or after a verification check, update the status.
- On startup, replay any
pendingactions: query the relevantGet*RPC, decide if the action completed, and update.
For order placement, the simplest correct scheme is to make
client_order_idbe youraction_id: replaying a pending action then converges on the same order rather than creating a second one. (2026-10-14) For a liquidity request, makeclient_request_idyouraction_idthe same way.
Debugging Tips
- Enable verbose logging - Log all requests and responses
- Check error.message - Contains detailed error information
- Verify request format - Use protobuf debugging tools
- Test with small amounts - Use minimal values when debugging
- Check network status - Ensure blockchain is synced
- Monitor rate limits - Avoid excessive requests