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
5 changes: 0 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,6 @@ dmypy.json
# Cython debug symbols
cython_debug/

# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. This file currently
# ignores the entire .idea folder as a more nuclear option (not recommended).
.idea/

# Abstra
Expand Down
6 changes: 6 additions & 0 deletions src/decibel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
get_testc_address,
get_usdc_address,
)
from decibel._exceptions import (
TxnConfirmError,
TxnSubmitError,
)
from decibel._fee_pay import (
PendingTransactionResponse,
submit_fee_paid_transaction,
Expand Down Expand Up @@ -143,6 +147,8 @@
"DecibelAdminDex",
"DecibelAdminDexSync",
"ABIErrorEntry",
"TxnConfirmError",
"TxnSubmitError",
"ABISummary",
"AbiRegistry",
"amount_to_chain_units",
Expand Down
114 changes: 94 additions & 20 deletions src/decibel/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from aptos_sdk.ed25519 import Signature as Ed25519Signature
from aptos_sdk.transactions import FeePayerRawTransaction, SignedTransaction

from ._constants import DEFAULT_TXN_CONFIRM_TIMEOUT, DEFAULT_TXN_SUBMIT_TIMEOUT
from ._exceptions import TxnConfirmError, TxnSubmitError
from ._fee_pay import (
PendingTransactionResponse,
submit_fee_paid_transaction,
Expand Down Expand Up @@ -170,19 +172,25 @@ async def submit_tx(
self,
transaction: SimpleTransaction,
sender_authenticator: AccountAuthenticator,
*,
txn_submit_timeout: float | None = None,
) -> PendingTransactionResponse:
if self._no_fee_payer:
return await self._submit_direct(transaction, sender_authenticator)
return await self._submit_direct(transaction, sender_authenticator, txn_submit_timeout)
return await submit_fee_paid_transaction(
self._config,
transaction,
sender_authenticator,
txn_submit_timeout=txn_submit_timeout,
)

async def _send_tx(
self,
payload: InputEntryFunctionData,
account_override: Account | None = None,
*,
txn_submit_timeout: float | None = None,
txn_confirm_timeout: float | None = None,
) -> dict[str, Any]:
signer = account_override if account_override is not None else self._account
sender = signer.address()
Expand Down Expand Up @@ -216,9 +224,37 @@ async def _send_tx(

sender_authenticator = self._sign_transaction(signer, transaction)

pending_tx = await self.submit_tx(transaction, sender_authenticator)
if txn_submit_timeout is None:
txn_submit_timeout = DEFAULT_TXN_SUBMIT_TIMEOUT

return await self._wait_for_transaction(pending_tx.hash)
try:
pending_tx = await self.submit_tx(
transaction, sender_authenticator, txn_submit_timeout=txn_submit_timeout
)
except httpx.ConnectTimeout as e:
raise TxnSubmitError(
f"Failed to submit transaction: connection timeout to {self._config.fullnode_url}",
original_exception=e,
) from e
except httpx.ConnectError as e:
raise TxnSubmitError(
f"Failed to submit transaction: connection error - {e}",
original_exception=e,
) from e
except httpx.HTTPStatusError as e:
raise TxnSubmitError(
f"Failed to submit transaction: HTTP {e.response.status_code}",
original_exception=e,
) from e
Comment on lines +244 to +248
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

httpx.HTTPStatusError is unlikely to be raised here because none of the submit helpers call response.raise_for_status(); non-2xx responses are converted into ValueError in _submit_direct / _fee_pay. This except block is effectively dead code and can mislead readers; either remove it or switch submission helpers to use response.raise_for_status() consistently.

Suggested change
except httpx.HTTPStatusError as e:
raise TxnSubmitError(
f"Failed to submit transaction: HTTP {e.response.status_code}",
original_exception=e,
) from e

Copilot uses AI. Check for mistakes.
except Exception as e:
raise TxnSubmitError(
f"Failed to submit transaction: {e}",
original_exception=e,
) from e

return await self._wait_for_transaction(
pending_tx.hash, txn_confirm_timeout=txn_confirm_timeout
)
Comment on lines +255 to +257
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New public behavior is introduced here (separate submit vs confirm timeouts and new TxnSubmitError/TxnConfirmError types). The repo uses pytest, but there don't appear to be tests covering these timeout paths and exception mapping. Adding unit tests (e.g., mocking httpx to force timeouts, non-success submission responses, and confirmation polling timeouts/VM failures) would help prevent regressions and document intended retry semantics.

Suggested change
return await self._wait_for_transaction(
pending_tx.hash, txn_confirm_timeout=txn_confirm_timeout
)
try:
return await self._wait_for_transaction(
pending_tx.hash, txn_confirm_timeout=txn_confirm_timeout
)
except TxnConfirmError:
raise
except httpx.ConnectTimeout as e:
raise TxnConfirmError(
f"Failed to confirm transaction {pending_tx.hash}: connection timeout to {self._config.fullnode_url}",
original_exception=e,
) from e
except httpx.ConnectError as e:
raise TxnConfirmError(
f"Failed to confirm transaction {pending_tx.hash}: connection error - {e}",
original_exception=e,
) from e
except httpx.HTTPStatusError as e:
raise TxnConfirmError(
f"Failed to confirm transaction {pending_tx.hash}: HTTP {e.response.status_code}",
original_exception=e,
) from e
except Exception as e:
raise TxnConfirmError(
f"Failed to confirm transaction {pending_tx.hash}: {e}",
original_exception=e,
) from e

Copilot uses AI. Check for mistakes.

def _sign_transaction(
self,
Expand Down Expand Up @@ -283,6 +319,7 @@ async def _submit_direct(
self,
transaction: SimpleTransaction,
sender_authenticator: AccountAuthenticator,
txn_submit_timeout: float | None = None,
) -> PendingTransactionResponse:
url = f"{self._config.fullnode_url}/transactions"
headers = self._build_node_headers()
Expand All @@ -291,7 +328,9 @@ async def _submit_direct(
bcs_bytes = self._serialize_signed_transaction(transaction, sender_authenticator)

async with httpx.AsyncClient() as client:
response = await client.post(url, content=bcs_bytes, headers=headers)
response = await client.post(
url, content=bcs_bytes, headers=headers, timeout=txn_submit_timeout
)

if not response.is_success:
raise ValueError(
Expand All @@ -313,9 +352,11 @@ async def _submit_direct(
async def _wait_for_transaction(
self,
tx_hash: str,
timeout_secs: float = 30.0,
txn_confirm_timeout: float | None = None, # Uses DEFAULT_TXN_CONFIRM_TIMEOUT if None
poll_interval_secs: float = 1.0,
) -> dict[str, Any]:
if txn_confirm_timeout is None:
txn_confirm_timeout = DEFAULT_TXN_CONFIRM_TIMEOUT
url = f"{self._config.fullnode_url}/transactions/by_hash/{tx_hash}"
headers = self._build_node_headers()
start_time = time.time()
Expand All @@ -333,12 +374,10 @@ async def _wait_for_transaction(
return data
elif data.get("success") is False:
vm_status = data.get("vm_status", "Unknown error")
raise ValueError(f"Transaction failed: {vm_status}")
raise TxnConfirmError(tx_hash, f"failed: {vm_status}")

if time.time() - start_time > timeout_secs:
raise TimeoutError(
f"Transaction {tx_hash} did not complete within {timeout_secs}s"
)
if time.time() - start_time > txn_confirm_timeout:
raise TxnConfirmError(tx_hash, f"did not confirm within {txn_confirm_timeout}s")

await self._async_sleep(poll_interval_secs)

Expand Down Expand Up @@ -495,19 +534,25 @@ def submit_tx(
self,
transaction: SimpleTransaction,
sender_authenticator: AccountAuthenticator,
*,
txn_submit_timeout: float | None = None,
) -> PendingTransactionResponse:
if self._no_fee_payer:
return self._submit_direct(transaction, sender_authenticator)
return self._submit_direct(transaction, sender_authenticator, txn_submit_timeout)
return submit_fee_paid_transaction_sync(
self._config,
transaction,
sender_authenticator,
txn_submit_timeout=txn_submit_timeout,
)

def _send_tx(
self,
payload: InputEntryFunctionData,
account_override: Account | None = None,
*,
txn_submit_timeout: float | None = None,
txn_confirm_timeout: float | None = None,
) -> dict[str, Any]:
signer = account_override if account_override is not None else self._account
sender = signer.address()
Expand Down Expand Up @@ -541,9 +586,35 @@ def _send_tx(

sender_authenticator = self._sign_transaction(signer, transaction)

pending_tx = self.submit_tx(transaction, sender_authenticator)
if txn_submit_timeout is None:
txn_submit_timeout = DEFAULT_TXN_SUBMIT_TIMEOUT

return self._wait_for_transaction(pending_tx.hash)
try:
pending_tx = self.submit_tx(
transaction, sender_authenticator, txn_submit_timeout=txn_submit_timeout
)
except httpx.ConnectTimeout as e:
raise TxnSubmitError(
f"Failed to submit transaction: connection timeout to {self._config.fullnode_url}",
original_exception=e,
) from e
except httpx.ConnectError as e:
raise TxnSubmitError(
f"Failed to submit transaction: connection error - {e}",
original_exception=e,
) from e
except httpx.HTTPStatusError as e:
raise TxnSubmitError(
f"Failed to submit transaction: HTTP {e.response.status_code}",
original_exception=e,
) from e
Comment on lines +606 to +610
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the async version: catching httpx.HTTPStatusError here appears unreachable without response.raise_for_status(). Consider removing this handler or changing the submission code to raise HTTPStatusError intentionally for non-success responses.

Suggested change
except httpx.HTTPStatusError as e:
raise TxnSubmitError(
f"Failed to submit transaction: HTTP {e.response.status_code}",
original_exception=e,
) from e

Copilot uses AI. Check for mistakes.
except Exception as e:
raise TxnSubmitError(
f"Failed to submit transaction: {e}",
original_exception=e,
) from e

return self._wait_for_transaction(pending_tx.hash, txn_confirm_timeout=txn_confirm_timeout)

def _sign_transaction(
self,
Expand Down Expand Up @@ -614,14 +685,17 @@ def _submit_direct(
self,
transaction: SimpleTransaction,
sender_authenticator: AccountAuthenticator,
txn_submit_timeout: float | None = None,
) -> PendingTransactionResponse:
url = f"{self._config.fullnode_url}/transactions"
headers = self._build_node_headers()
headers["Content-Type"] = "application/x.aptos.signed_transaction+bcs"
bcs_bytes = self._serialize_signed_transaction(transaction, sender_authenticator)

def make_request(client: httpx.Client) -> PendingTransactionResponse:
response = client.post(url, content=bcs_bytes, headers=headers)
response = client.post(
url, content=bcs_bytes, headers=headers, timeout=txn_submit_timeout
)
if not response.is_success:
raise ValueError(
f"Transaction submission failed: {response.status_code} - {response.text}"
Expand All @@ -645,9 +719,11 @@ def make_request(client: httpx.Client) -> PendingTransactionResponse:
def _wait_for_transaction(
self,
tx_hash: str,
timeout_secs: float = 30.0,
txn_confirm_timeout: float | None = None, # Uses DEFAULT_TXN_CONFIRM_TIMEOUT if None
poll_interval_secs: float = 1.0,
) -> dict[str, Any]:
if txn_confirm_timeout is None:
txn_confirm_timeout = DEFAULT_TXN_CONFIRM_TIMEOUT
url = f"{self._config.fullnode_url}/transactions/by_hash/{tx_hash}"
headers = self._build_node_headers()
start_time = time.time()
Expand All @@ -664,11 +740,9 @@ def poll_loop(client: httpx.Client) -> dict[str, Any]:
return data
elif data.get("success") is False:
vm_status = data.get("vm_status", "Unknown error")
raise ValueError(f"Transaction failed: {vm_status}")
if time.time() - start_time > timeout_secs:
raise TimeoutError(
f"Transaction {tx_hash} did not complete within {timeout_secs}s"
)
raise TxnConfirmError(tx_hash, f"failed: {vm_status}")
if time.time() - start_time > txn_confirm_timeout:
raise TxnConfirmError(tx_hash, f"did not confirm within {txn_confirm_timeout}s")
time.sleep(poll_interval_secs)

if self._http_client is not None:
Expand Down
10 changes: 10 additions & 0 deletions src/decibel/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"Deployment",
"DecibelConfig",
"DEFAULT_COMPAT_VERSION",
"DEFAULT_TXN_CONFIRM_TIMEOUT",
"DEFAULT_TXN_SUBMIT_TIMEOUT",
"MAINNET_CONFIG",
"NETNA_CONFIG",
"TESTNET_CONFIG",
Expand All @@ -22,6 +24,14 @@
"get_perp_engine_global_address",
]

# Configurable timeout for transaction confirmation
# Default is 30 seconds
DEFAULT_TXN_CONFIRM_TIMEOUT = 30.0

# Configurable timeout for transaction submission
# Default is 10 seconds (should be shorter than confirmation timeout)
DEFAULT_TXN_SUBMIT_TIMEOUT = 10.0


class Network(str, Enum):
MAINNET = "mainnet"
Expand Down
51 changes: 51 additions & 0 deletions src/decibel/_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Custom exceptions for the Decibel SDK.

These exceptions help callers distinguish between failures that are safe to retry
(submission errors) vs failures that require checking transaction status first
(confirmation errors).
"""

from __future__ import annotations


class TxnConfirmError(Exception):
"""
Transaction was submitted but confirmation failed.

Causes:
- Transaction did not confirm within timeout (still pending or dropped)
- Transaction executed but reverted (VM error)
- Transaction failed during execution

CRITICAL: The transaction MAY be on-chain. Check tx_hash status before retrying
to avoid duplicate transactions.

Attributes:
tx_hash: The transaction hash that was submitted
message: Description of what went wrong
"""

def __init__(self, tx_hash: str, message: str) -> None:
self.tx_hash = tx_hash
super().__init__(f"Transaction {tx_hash}: {message}")


class TxnSubmitError(Exception):
"""
Transaction submission failed before reaching the blockchain.

Causes:
- Network connectivity issues (timeout, connection refused)
- RPC endpoint unavailable
- HTTP errors (5xx, 429 rate limit)
- Serialization errors

SAFE TO RETRY: The transaction was never submitted to the blockchain.

Attributes:
original_exception: The underlying exception that caused the failure
"""

def __init__(self, message: str, original_exception: Exception | None = None) -> None:
self.original_exception = original_exception
super().__init__(message)
Loading