Skip to content

Commit ff36ffa

Browse files
authored
Merge pull request #54 from daveades/feat/35-stellar-error
feat(errors): implement StellarError (#35)
2 parents da59a32 + 8566421 commit ff36ffa

3 files changed

Lines changed: 574 additions & 1 deletion

File tree

src/shade/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
RateLimitError,
1616
ShadeError,
1717
SignatureVerificationError,
18+
StellarError,
19+
wrap_stellar_errors,
1820
)
1921
from .models import (
2022
AssetBalance,
@@ -49,6 +51,7 @@
4951
"ShadeError",
5052
"SignatureVerificationError",
5153
"ShadeObject",
54+
"StellarError",
5255
"SyncHTTPClient",
5356
"Transfer",
5457
"TransferStatus",
@@ -61,6 +64,7 @@
6164
"environment",
6265
"max_retries",
6366
"timeout",
67+
"wrap_stellar_errors",
6468
]
6569

6670
class _ShadeModule(ModuleType):

src/shade/errors.py

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from __future__ import annotations
55

66
import json
7-
from typing import Any, Optional
7+
from contextlib import contextmanager
8+
from typing import Any, Generator, List, Optional, Tuple
9+
10+
from stellar_sdk.exceptions import BaseHorizonError, SdkError
811

912
INVALID_REQUEST_STATUS_CODES = (400, 422)
1013

@@ -201,6 +204,225 @@ class NetworkError(ShadeError):
201204
"""Raised when the SDK cannot complete a network request."""
202205

203206

207+
OPERATION_SUCCESS_CODE = "op_success"
208+
209+
# Human-readable descriptions for the Stellar result codes this SDK is most
210+
# likely to surface. Codes absent from these tables fall back to the raw code,
211+
# so an unrecognised code still reaches the caller intact.
212+
TRANSACTION_RESULT_CODE_DESCRIPTIONS: dict[str, str] = {
213+
"tx_failed": "one or more operations in the transaction failed",
214+
"tx_too_early": "the ledger closed before the transaction's minimum time bound",
215+
"tx_too_late": "the ledger closed after the transaction's maximum time bound",
216+
"tx_missing_operation": "the transaction contained no operations",
217+
"tx_bad_seq": "the sequence number does not match the source account",
218+
"tx_bad_auth": "too few valid signatures, or the wrong network was used",
219+
"tx_bad_auth_extra": "the transaction carries signatures that were not needed",
220+
"tx_insufficient_balance": "the fee would drop the source account below its minimum reserve",
221+
"tx_insufficient_fee": "the fee offered is below the network minimum",
222+
"tx_no_source_account": "the source account does not exist on the network",
223+
"tx_internal_error": "Horizon reported an unknown internal error",
224+
"tx_not_supported": "the network does not support this transaction",
225+
"tx_fee_bump_inner_failed": "the inner transaction of the fee bump failed",
226+
}
227+
228+
OPERATION_RESULT_CODE_DESCRIPTIONS: dict[str, str] = {
229+
"op_malformed": "the operation is malformed",
230+
"op_underfunded": "the source account does not hold enough of the asset",
231+
"op_src_no_trust": "the source account has no trustline for the asset",
232+
"op_src_not_authorized": "the source account is not authorized to send the asset",
233+
"op_no_destination": "the destination account does not exist on the network",
234+
"op_no_trust": "the destination account has no trustline for the asset",
235+
"op_not_authorized": "the destination account is not authorized to hold the asset",
236+
"op_line_full": "the transfer would exceed the destination account's trustline limit",
237+
"op_no_issuer": "the issuer of the asset does not exist",
238+
"op_low_reserve": "the resulting account would fall below its minimum reserve",
239+
"op_bad_auth": "the operation was not signed by enough authorized signers",
240+
"op_no_account": "the source account of the operation does not exist",
241+
"op_not_supported": "the network does not support this operation",
242+
"change_trust_no_issuer": "the issuer of the asset does not exist",
243+
"change_trust_invalid_limit": "the requested trustline limit is invalid",
244+
"change_trust_low_reserve": "the account cannot cover the reserve for a new trustline",
245+
"change_trust_self_not_allowed": "an account cannot create a trustline to itself",
246+
}
247+
248+
249+
class StellarError(ShadeError):
250+
"""Raised when a Stellar/Horizon call fails.
251+
252+
Wraps the underlying ``stellar_sdk`` exception so callers keep access to the
253+
raw error while still handling a single SDK exception type. Build one from a
254+
caught ``stellar_sdk`` exception with :meth:`from_exception`, or let
255+
:func:`wrap_stellar_errors` do it for a whole block.
256+
257+
Attributes:
258+
stellar_result_code: The transaction-level Horizon result code (e.g.
259+
``"tx_failed"``, ``"tx_insufficient_fee"``). Falls back to the first
260+
failing operation code when Horizon reported no transaction code, and
261+
is ``None`` when the failure carried no result codes at all.
262+
operation_result_codes: Per-operation result codes exactly as Horizon
263+
ordered them, including any ``"op_success"`` entries. Empty when the
264+
failure was not a rejected transaction.
265+
original_error: The raw ``stellar_sdk`` exception, or ``None`` when the
266+
error was constructed directly.
267+
"""
268+
269+
def __init__(
270+
self,
271+
message: str,
272+
stellar_result_code: Optional[str] = None,
273+
original_error: Optional[Exception] = None,
274+
status_code: Optional[int] = None,
275+
response_body: Optional[str] = None,
276+
operation_result_codes: Optional[List[str]] = None,
277+
) -> None:
278+
super().__init__(message, status_code, response_body)
279+
self.stellar_result_code = stellar_result_code
280+
self.original_error = original_error
281+
self.operation_result_codes: List[str] = list(operation_result_codes or [])
282+
283+
@property
284+
def failed_operation_code(self) -> Optional[str]:
285+
"""The first operation result code that is not ``"op_success"``.
286+
287+
Lets callers branch on the specific failure (``op_no_trust``,
288+
``op_underfunded``, …) without walking
289+
:attr:`operation_result_codes` themselves.
290+
"""
291+
for code in self.operation_result_codes:
292+
if code != OPERATION_SUCCESS_CODE:
293+
return code
294+
return None
295+
296+
def __str__(self) -> str:
297+
message = self.message
298+
if self.stellar_result_code:
299+
message = f"{message} (result code: {self.stellar_result_code})"
300+
if self.status_code is None:
301+
return message
302+
return f"{message} (status code: {self.status_code})"
303+
304+
@classmethod
305+
def from_exception(
306+
cls,
307+
exc: Exception,
308+
message: Optional[str] = None,
309+
) -> "StellarError":
310+
"""Wrap a ``stellar_sdk`` exception, pulling out any Horizon result codes.
311+
312+
Args:
313+
exc: The caught ``stellar_sdk`` exception.
314+
message: Overrides the message derived from the result codes. Useful
315+
for adding operation context the exception cannot know about
316+
(e.g. "Failed to submit payout txn_123").
317+
"""
318+
transaction_code, operation_codes = _stellar_result_codes(exc)
319+
status_code, response_body = _horizon_context(exc)
320+
return cls(
321+
message or _stellar_failure_message(exc, transaction_code, operation_codes),
322+
stellar_result_code=transaction_code
323+
or next((c for c in operation_codes if c != OPERATION_SUCCESS_CODE), None),
324+
original_error=exc,
325+
status_code=status_code,
326+
response_body=response_body,
327+
operation_result_codes=operation_codes,
328+
)
329+
330+
331+
@contextmanager
332+
def wrap_stellar_errors(message: Optional[str] = None) -> Generator[None, None, None]:
333+
"""Re-raise any ``stellar_sdk`` failure inside the block as :class:`StellarError`.
334+
335+
The Stellar integration layer wraps its Horizon and Soroban calls with this
336+
so callers only ever have to catch :class:`~shade.errors.ShadeError`::
337+
338+
with wrap_stellar_errors("Failed to submit payment"):
339+
server.submit_transaction(transaction)
340+
341+
Args:
342+
message: Overrides the derived message on the raised ``StellarError``.
343+
The result codes and original exception are attached either way.
344+
"""
345+
try:
346+
yield
347+
except SdkError as exc:
348+
raise StellarError.from_exception(exc, message=message) from exc
349+
350+
351+
def _stellar_result_codes(exc: Exception) -> Tuple[Optional[str], List[str]]:
352+
"""Return ``(transaction_code, operation_codes)`` from a Horizon error.
353+
354+
Horizon reports these under ``extras.result_codes``. Every level is
355+
type-checked rather than assumed, so a malformed or partial error body
356+
degrades to "no codes" instead of raising while building an exception.
357+
"""
358+
extras = getattr(exc, "extras", None)
359+
if not isinstance(extras, dict):
360+
return None, []
361+
result_codes = extras.get("result_codes")
362+
if not isinstance(result_codes, dict):
363+
return None, []
364+
365+
transaction = result_codes.get("transaction")
366+
operations = result_codes.get("operations")
367+
return (
368+
transaction if isinstance(transaction, str) else None,
369+
[code for code in operations if isinstance(code, str)]
370+
if isinstance(operations, list)
371+
else [],
372+
)
373+
374+
375+
def _horizon_context(exc: Exception) -> Tuple[Optional[int], Optional[str]]:
376+
"""Return ``(status_code, response_body)`` for a Horizon error, else ``(None, None)``."""
377+
if not isinstance(exc, BaseHorizonError):
378+
return None, None
379+
status = getattr(exc, "status", None)
380+
body = getattr(exc, "message", None)
381+
return (
382+
status if isinstance(status, int) else None,
383+
body if isinstance(body, str) else None,
384+
)
385+
386+
387+
def _stellar_failure_message(
388+
exc: Exception,
389+
transaction_code: Optional[str],
390+
operation_codes: List[str],
391+
) -> str:
392+
"""Build a human-readable message for a Stellar failure.
393+
394+
Prefers the most specific signal available: a failing operation code first
395+
(that is what actually went wrong), then the transaction code, then whatever
396+
Horizon or the exception itself described.
397+
"""
398+
operation_code = next(
399+
(code for code in operation_codes if code != OPERATION_SUCCESS_CODE), None
400+
)
401+
if operation_code is not None:
402+
description = OPERATION_RESULT_CODE_DESCRIPTIONS.get(operation_code)
403+
if description:
404+
return f"Stellar transaction failed: {description} ({operation_code})"
405+
return f"Stellar transaction failed: {operation_code}"
406+
407+
if transaction_code is not None:
408+
description = TRANSACTION_RESULT_CODE_DESCRIPTIONS.get(transaction_code)
409+
return f"Stellar transaction failed: {description or transaction_code}"
410+
411+
account_id = getattr(exc, "account_id", None)
412+
if account_id:
413+
return f"Stellar account {account_id} does not exist on the network"
414+
415+
title = getattr(exc, "title", None)
416+
detail = getattr(exc, "detail", None)
417+
if title and detail:
418+
return f"Stellar request failed: {title} - {detail}"
419+
if title or detail:
420+
return f"Stellar request failed: {title or detail}"
421+
422+
text = str(exc).strip()
423+
return f"Stellar request failed: {text or type(exc).__name__}"
424+
425+
204426
def raise_for_invalid_request(
205427
status_code: int,
206428
response_body: Optional[str] = None,

0 commit comments

Comments
 (0)