auradefi 0.1.2
PyPI GitHub

How do I make each error happen on purpose, so I can test my handler?

Run it

pip install auradefi
python examples/11_provoke_every_error.py

Reading which errors exist tells you nothing about whether your except clause works. This file causes sixteen of them deliberately, offline, in three lines each, so you can copy the trigger into your own test suite and watch your handler run.

Every trigger below is deterministic. None needs a key, a network or a database, and none depends on an upstream service being in a bad mood.

The errors are grouped by whose problem they are, because that is what decides what you do about one:

your call is wrong      fix the code, the input never reaches a service
your data disagrees     two values that cannot be combined met
a credential is bad     your customer's token or your quota, not a bug
upstream said no        the network, or a recording that lacks a request

Over HTTP each of these becomes {"error": {"type", "message", "status", "docs_url"}}, and docs_url is the errors page anchored at the row for that type. This file prints the anchor beside each one, so you can see the correspondence between the exception you catch in Python and the body your own API clients will read.

What it prints

your call is wrong: fix the code, no request was made
  CaipParseError         not a canonical eip155 CAIP-2: 'ethereum'
                         https://auradefi.info/errors.html#caipparseerror
  UnknownChainError      unknown chain 'eip155:999999': CAIP-2 is the only key
                         https://auradefi.info/errors.html#unknownchainerror
  ValidationError        budget must be >= 1, got 0
                         https://auradefi.info/errors.html#validationerror
  ConflictError          connection already exists: 'conn_909637d920351ab9'
                         https://auradefi.info/errors.html#conflicterror
  NotFoundError          transaction not found: 'txn_nope'
                         https://auradefi.info/errors.html#notfounderror
  CursorError            cursor token must be exactly 20 ASCII digits: 'page-2'
                         https://auradefi.info/errors.html#cursorerror
  ConfigError            AURADEFI_HTTP_TIMEOUT_S must be a float: 'soon'
                         https://auradefi.info/errors.html#configerror

  the 409 names what you already own: conn_909637d920351ab9

your data disagrees: two values that cannot be combined met
  CurrencyMismatchError  currency mismatch: 'USD' vs 'GBP'
                         https://auradefi.info/errors.html#currencymismatcherror
  UnknownAssetError      unknown asset id 'ast_deadbeefdeadbeef'
                         https://auradefi.info/errors.html#unknownasseterror

a credential is bad: 4xx over HTTP, and not a bug
  AuthError              token failed authentication
                         https://auradefi.info/errors.html#autherror
  TokenExpiredError      token expired
                         https://auradefi.info/errors.html#tokenexpirederror
  TokenRevokedError      token revoked
                         https://auradefi.info/errors.html#tokenrevokederror
  QuotaExceededError     quota exceeded in the 'second' window for 'proj_demo': lim
                         https://auradefi.info/errors.html#quotaexceedederror

upstream said no: the network, or a recording without the request
  SourceError            etherscan balance error: message='NOTOK'
                         https://auradefi.info/errors.html#sourceerror
  CassetteMissError      GET https://api.etherscan.io/v2/api?chainid=1&module=accou
                         https://auradefi.info/errors.html#cassettemisserror
  CassetteError          cassette not found: no/such/recording.json
                         https://auradefi.info/errors.html#cassetteerror

one `except AuradefiError` caught all four: CaipParseError, CurrencyMismatchError, SourceError, CassetteError

the same error, in the two places it can reach you
  SourceError            etherscan txlist error: message='NOTOK'
                         https://auradefi.info/errors.html#sourceerror
  contained on the tick: 1 connection failed, no_op=False, and sync() returned a report

OK: sixteen errors, all on purpose, none of them a surprise.

The whole file

11_provoke_every_error.py on GitHub: self-contained, offline, asserts its own output.

"""How do I make each error happen on purpose, so I can test my handler?

    pip install auradefi
    python examples/11_provoke_every_error.py

Reading which errors exist tells you nothing about whether your `except`
clause works. This file causes sixteen of them deliberately, offline, in
three lines each, so you can copy the trigger into your own test suite and
watch your handler run.

Every trigger below is deterministic. None needs a key, a network or a
database, and none depends on an upstream service being in a bad mood.

The errors are grouped by whose problem they are, because that is what
decides what you do about one:

    your call is wrong      fix the code, the input never reaches a service
    your data disagrees     two values that cannot be combined met
    a credential is bad     your customer's token or your quota, not a bug
    upstream said no        the network, or a recording that lacks a request

Over HTTP each of these becomes `{"error": {"type", "message", "status",
"docs_url"}}`, and `docs_url` is the errors page anchored at the row for
that type. This file prints the anchor beside each one, so you can see the
correspondence between the exception you catch in Python and the body your
own API clients will read.
"""

from __future__ import annotations

from decimal import Decimal

from auradefi import Auradefi
from auradefi.assets.registry import AssetRegistry
from auradefi.clock import FrozenClock
from auradefi.config import Settings
from auradefi.errors import (
    AuradefiError,
    AuthError,
    CaipParseError,
    CassetteError,
    CassetteMissError,
    ConfigError,
    ConflictError,
    CurrencyMismatchError,
    CursorError,
    NotFoundError,
    QuotaExceededError,
    SourceError,
    TokenExpiredError,
    TokenRevokedError,
    UnknownAssetError,
    UnknownChainError,
    ValidationError,
)
from auradefi.ledger.backends.memory import MemoryLedger
from auradefi.money.fiat import Money
from auradefi.tenancy.quota import QuotaCounter, QuotaLimits
from auradefi.tenancy.tokens import RevocationSet, mint_token, verify_token
from auradefi.testing.cassettes import load

#: The address the bundled Sandbox recording holds. Anything else misses.
RECORDED = "0x1111111111111111111111111111111111111111"
UNRECORDED = "0x2222222222222222222222222222222222222222"

#: The page every error body links to. `api/errors.py` builds this from the
#: exception's own class, so it is right without a table to maintain.
DOCS = "https://auradefi.info/errors.html"

NOW = 1_754_000_000_000
SECRET = "s" * 64


def provoke(expected: type[AuradefiError], label: str, trigger) -> None:
    """Run `trigger`, require `expected`, and show what a caller would see."""
    try:
        trigger()
    except AuradefiError as raised:
        assert isinstance(raised, expected), (
            f"{label}: expected {expected.__name__}, got {type(raised).__name__}"
        )
        name = type(raised).__name__
        print(f"  {name:<22} {str(raised)[:58]}")
        print(f"  {'':<22} {DOCS}#{name.lower()}")
        return
    raise AssertionError(f"{label}: nothing raised, so nothing was proved")


# ------------------------------------------------------- your call is wrong
# Every one of these is caught before a request leaves the process, so a
# wrong chain id or a zero budget costs you nothing but the exception.

print("your call is wrong: fix the code, no request was made")

aura = Auradefi.sandbox()
user = aura.user("demo-user")

provoke(CaipParseError, "a chain name instead of a CAIP-2 id",
        lambda: user.connect_address("ethereum", RECORDED))

provoke(UnknownChainError, "a CAIP-2 id the registry was never given",
        lambda: user.connect_address("eip155:999999", RECORDED))

provoke(ValidationError, "a budget that cannot fetch a page",
        lambda: aura.sync(budget=0))

# This one succeeds, and the next one needs it to have succeeded.
user.connect_address("eip155:1", RECORDED)

provoke(ConflictError, "the same address on the same chain twice",
        lambda: user.connect_address("eip155:1", RECORDED))

ledger = MemoryLedger()
provoke(NotFoundError, "a transaction id nobody stored",
        lambda: ledger.get("usr_absent", "txn_nope"))

provoke(CursorError, "a cursor the caller made up",
        lambda: ledger.sync("usr_absent", cursor="page-2"))

provoke(ConfigError, "a timeout that is not a number",
        lambda: Settings.from_env({"AURADEFI_HTTP_TIMEOUT_S": "soon"}))

# A 409 is the one refusal that hands back something usable: the id of the
# connection you already have, so a retry can adopt it instead of guessing.
try:
    user.connect_address("eip155:1", RECORDED)
except ConflictError as conflict:
    assert conflict.existing_id is not None
    assert conflict.existing_id.startswith("conn_")
    print(f"\n  the 409 names what you already own: {conflict.existing_id}")


# ------------------------------------------------------- your data disagrees
# Two values met that cannot be combined. Nothing is broken upstream and
# nothing is wrong with your credentials.

print("\nyour data disagrees: two values that cannot be combined met")

provoke(CurrencyMismatchError, "adding two currencies",
        lambda: Money(Decimal("1"), "USD") + Money(Decimal("1"), "GBP"))

provoke(UnknownAssetError, "an asset id that was never registered",
        lambda: AssetRegistry().get_by_id("ast_deadbeefdeadbeef"))


# --------------------------------------------------------- a credential is bad
# These are your customer's problem to re-authenticate, or your quota to
# raise. None of them is a defect, and all four are 4xx over HTTP.

print("\na credential is bad: 4xx over HTTP, and not a bug")

clock = FrozenClock(NOW)
token = mint_token(
    project_id="proj_demo",
    external_user_id="end-user-1",
    scopes=("accounts:read",),
    signing_secret=SECRET,
    clock=clock,
    ttl_ms=60_000,
)

provoke(AuthError, "a token verified against the wrong project's secret",
        lambda: verify_token(token, signing_secret="w" * 64, clock=clock))

provoke(TokenExpiredError, "a token read after its expiry",
        lambda: verify_token(token, signing_secret=SECRET,
                             clock=FrozenClock(NOW + 3_600_000)))

revoked = RevocationSet()
revoked.revoke(verify_token(token, signing_secret=SECRET, clock=clock).jti)
provoke(TokenRevokedError, "a token whose jti was revoked",
        lambda: verify_token(token, signing_secret=SECRET, clock=clock,
                             revoked=revoked))

quota = QuotaCounter(QuotaLimits(per_second=1, per_day=10, per_month=100), clock)
quota.hit("proj_demo")
provoke(QuotaExceededError, "the second request in a one-per-second window",
        lambda: quota.hit("proj_demo"))


# ------------------------------------------------------------ upstream said no
# The only group that is about the world outside your process.

print("\nupstream said no: the network, or a recording without the request")


class RefusingSource:
    """A source whose upstream can be taken down mid-run.

    Both seams raise `SourceError`, which is the contract: raise that (or
    any `AuradefiError`) for an upstream problem and `sync()` files it
    against the one connection it belongs to.
    """

    def __init__(self, up: bool = True) -> None:
        self.up = up

    def balances(self, chain_id: str, address: str) -> list:
        if self.up:
            return []
        raise SourceError("etherscan balance error: message='NOTOK'")

    def fetch_txlist(self, chain_id, address, **window) -> list[dict]:
        if self.up:
            return []
        raise SourceError("etherscan txlist error: message='NOTOK'")


class EmptyPrices:
    """One method, and returning nothing is allowed rather than an error."""

    def usd_prices(self, caip19s) -> dict:
        return {}


provoke(SourceError, "an upstream that refuses",
        lambda: RefusingSource(up=False).balances("eip155:1", RECORDED))

provoke(CassetteMissError, "asking Sandbox for an address it never recorded",
        lambda: aura.user("other").connect_address("eip155:1", UNRECORDED))

provoke(CassetteError, "a cassette file that is not there",
        lambda: load("no/such/recording.json"))


# ----------------------------------------- one except clause catches them all
# Every type above inherits AuradefiError, so a host that wants to catch this
# library and nothing else needs exactly one clause. Catching narrower is how
# you tell a caller mistake from an upstream failure.

caught = []
for trigger in (
    lambda: user.connect_address("ethereum", RECORDED),
    lambda: Money(Decimal("1"), "USD") + Money(Decimal("1"), "GBP"),
    lambda: RefusingSource(up=False).balances("eip155:1", RECORDED),
    lambda: load("no/such/recording.json"),
):
    try:
        trigger()
    except AuradefiError as raised:
        caught.append(type(raised).__name__)

assert len(caught) == 4, caught
print(f"\none `except AuradefiError` caught all four: {', '.join(caught)}")


# ------------------------------------------------ where the error reaches you
# The same SourceError arrives in two different places depending on when the
# upstream broke, and the difference is deliberate.

print("\nthe same error, in the two places it can reach you")

# 1. At connect time. `connect_address` spends one single-row request as a
#    liveness probe, so a dead endpoint or a bad key surfaces while your user
#    is still on the screen instead of on a tick tomorrow.
dead = RefusingSource(up=False)
early = Auradefi(MemoryLedger(), dead, EmptyPrices())
provoke(SourceError, "a dead upstream at connect time",
        lambda: early.user("tenant-a").connect_address("eip155:1", RECORDED))

# 2. On a later tick. Connect while it is healthy, lose it afterwards, and
#    sync() files the failure against that one connection: its siblings keep
#    their share of the budget and the tick still reports.
source = RefusingSource(up=True)
later = Auradefi(MemoryLedger(), source, EmptyPrices())
later.user("tenant-a").connect_address("eip155:1", RECORDED)
source.up = False
report = later.sync(budget=5)

assert report.failed_connections, "a refusing source must be reported failed"
assert not report.no_op, "a failure is never a success-shaped no-op"
print(f"  contained on the tick: {len(report.failed_connections)} connection "
      f"failed, no_op={report.no_op}, and sync() returned a report")

print("\nOK: sixteen errors, all on purpose, none of them a surprise.")