Parse the Sume error envelope into a Python dataclass and exception

Sume errors share one envelope: code, request_id, retryable, retry_after_seconds, next_action, category and stage. Turn it into a typed Python exception.

4 min readSume
All posts

Every error from the Sume API has the same shape, so one parser can serve every endpoint. The body is { "error": { ... } } and the object holds code, message, request_id, category, stage, retryable, retry_after_seconds, public_reason, next_action and, when relevant, details. A typed exception that carries those fields lets the rest of your code branch on them and never on message text.

Fields worth branching on

Fields in the Sume error envelope (read 2026-10-04)
FieldTypeUse
codestringStable identifier such as rate_limited or idempotency_conflict
request_idstringQuote it to support and in your logs
retryablebooleanWhether sending again can help
retry_after_secondsnumber or nullSeconds to wait before the retry
next_actionstringFor example retry_later, fix_input or contact_support

Why not the message

The message is for people and can change. The code and next_action are the contract. retryable is the field that keeps a 503 for a missing provider (do not retry) apart from a 503 for a redeploy (retry in 5 seconds).

Dataclass and exception

from_body accepts anything and still returns an object, so a proxy that replaces the body with HTML does not crash the handler. The status comes from the HTTP response and is kept alongside the envelope.

from dataclasses import dataclass, field

@dataclass
class SumeError(Exception):
    status: int
    code: str = "unknown"
    message: str = ""
    request_id: str | None = None
    retryable: bool = False
    retry_after_seconds: float | None = None
    next_action: str | None = None
    details: dict = field(default_factory=dict)

    @classmethod
    def from_body(cls, status, body):
        err = body.get("error", {}) if isinstance(body, dict) else {}
        return cls(status, err.get("code", "unknown"), err.get("message", ""), err.get("request_id"),
                   bool(err.get("retryable")), err.get("retry_after_seconds"),
                   err.get("next_action"), err.get("details") or {})

    def __str__(self):
        return f"{self.status} {self.code} (request {self.request_id}): {self.message}"

e = SumeError.from_body(503, {"error": {"code": "deploy_draining", "retryable": True, "retry_after_seconds": 5}})
print(e.retryable, e.retry_after_seconds)  # True 5

Use it

Raise it from your HTTP wrapper after any non-2xx response. Callers then write except SumeError as e: if e.retryable: .... Keep request_id in every log line, and the single value is what support needs to find the request.

Where the envelope appears

The envelope is not limited to one route. A 429 carries details with the limit and scope. A 400 unknown_parameter carries details.errors with a suggestion and details.accepted with the valid keys. A 503 carries the retry delay in retry_after_seconds. Because all of them share one shape, the details field is a plain dictionary in the dataclass and each caller reads only what it needs.

The response also carries an x-sume-request-id header, which the OpenAPI schema describes as the correlation id to include when you contact support. Record whichever is easier to reach in your stack, and log it next to the job id.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume