A Python SumeError class: code, retryable, next_action, request id

Turn a Sume error envelope into one Python exception that carries code, retryable, retry_after_seconds, next_action and the request id. Standard library only.

4 min readSume
All posts

urllib raises HTTPError for any status of 400 or above, and its message is just HTTP Error 402: Payment Required. The useful part is in the body. The Sume API wraps failures in an error object, and the fields your code should act on sit inside it. Wrapping them once, at the edge of your client, keeps the rest of the program from re-parsing JSON in every except block.

The envelope carries code, message, request_id, retryable, retry_after_seconds and next_action. The same request id is sent in the x-sume-request-id response header, which is the better source when the body is empty or is not JSON, such as a gateway error page.

What to copy out of the envelope

Fields of a Sume error object worth keeping, from the Sume docs and the SDK source (read 2026-10-03)
FieldUse it for
codeBranching, for example insufficient_credits or rate_limited
retryableWhether a retry can ever succeed without a change from you
retry_after_secondsHow long to wait first, when the API names a window
next_actionA hint such as add_funds that a human or a router can act on
request_idThe value to quote to support, also in x-sume-request-id

One exception, built at the edge

The class below reads the fields defensively with .get, because a missing field is normal. A 402 carries no retry hint, and a proxy error has no envelope at all. from None hides the original HTTPError so a log shows one clean line. The final lines run against a job id that returns the wallet error and print the fields.

import json, os, urllib.error, urllib.request

class SumeError(Exception):
    def __init__(self, status: int, body: dict, header_request_id: str | None):
        e = body.get("error") or {}
        super().__init__(f"{status} {e.get('code')}: {e.get('message')}")
        self.status, self.code = status, e.get("code")
        self.retryable = bool(e.get("retryable"))
        self.retry_after = e.get("retry_after_seconds")
        self.next_action = e.get("next_action")
        self.request_id = header_request_id or e.get("request_id")

def get(path: str) -> dict:
    h = {"x-api-key": os.environ["SUME_API_KEY"]}
    req = urllib.request.Request("https://api.sume.com/v1" + path, headers=h)
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        try:
            body = json.load(e)
        except ValueError:
            body = {}
        raise SumeError(e.code, body, e.headers.get("x-sume-request-id")) from None

try:
    get("/jobs/poor")
except SumeError as err:
    print(err, "| retryable:", err.retryable, "| next:", err.next_action, "| id:", err.request_id)

How to use it

  • Branch on status and code, as the docs advise. Treat category and stage as coarse labels for dashboards, not for control flow.
  • Retry only when retryable is true, and wait retry_after first when it is set. A 402 insufficient_credits carries retryable: false and next_action: add_funds, so a loop should stop and surface it.
  • Log request_id on every raised error. It is the one field that lets support find the exact request.
  • Keep the status in the exception. Some codes, such as idempotency_conflict, are a 409 that has its own recovery path, and a bare message would hide that.

The error fields are described in the errors reference, and the job routes that return them are in the jobs guide.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume