Errors
Every failure in the Plugipay Python SDK funnels through one exception class — PlugipayError — with three subclasses that let you distinguish transport-level failures from API-level ones. Catch the base class for "anything went wrong"; catch a subclass when you want to retry only specific failure modes.
from plugipay import PlugipayClient, PlugipayError
plug = PlugipayClient(key_id="…", secret="…")
try:
customer = plug.customers.get("cus_does_not_exist")
except PlugipayError as exc:
print(exc.status, exc.code, exc.message, exc.request_id)
# 404 not_found 'Customer cus_does_not_exist not found' 'req_01HX…'
The exception hierarchy
Exception
└── PlugipayError (base — every SDK failure)
├── PlugipayNetworkError (DNS, connect refused, TLS, socket reset)
├── PlugipayTimeoutError (request exceeded the configured timeout)
└── PlugipaySignatureError (verify_webhook only)
All four are importable from the top-level package:
from plugipay import (
PlugipayError,
PlugipayNetworkError,
PlugipayTimeoutError,
PlugipaySignatureError,
)
PlugipayError
The base class — raised for any failure that reaches the server and comes back as a non-2xx response, or any parsing failure on the response envelope.
Attributes:
| Attribute | Type | Meaning |
|---|---|---|
status |
int |
HTTP status code. 0 for client-side failures (timeout, network, bad signature) that never reached the server. |
code |
str |
Short machine-readable code — e.g. not_found, invalid_request, insufficient_scope. The full catalog is at API → Errors. |
message |
str |
Human-readable message. Same as str(exc). |
request_id |
str | None |
The meta.requestId echoed by the API. Always present on server responses; absent on client-side failures. |
PlugipayError extends Exception, so str(exc) returns message. You can also repr(exc) for the full attribute dump.
PlugipayNetworkError
Subclass of PlugipayError raised when httpx reports a transport-level failure: DNS resolution failed, connection refused, TLS handshake aborted, socket reset mid-response.
exc.status # 0 (request never reached the server)
exc.code # 'network_error'
exc.message # the underlying httpx error message
exc.request_id # None
These are usually transient and worth retrying with backoff. See Retry patterns.
PlugipayTimeoutError
Subclass of PlugipayError raised when a request exceeds the client's timeout (default 30 seconds).
exc.status # 0
exc.code # 'timeout'
exc.message # 'Plugipay request timed out after 30.0s'
exc.request_id # None
Like network errors, these are usually transient. But before retrying, ask yourself if the request was likely idempotent — otherwise you risk a duplicate. The SDK auto-attaches Idempotency-Key on writes that need it, so retrying a POST is usually safe; see Idempotency.
PlugipaySignatureError
Raised by verify_webhook() when an inbound webhook fails verification — missing header, malformed header, stale timestamp, signature mismatch. Status is 401, code is one of signature_missing, signature_malformed, signature_stale, signature_invalid. Reject the delivery with a 4xx so Plugipay retries (or doesn't — signature failures are usually a deliberate attack and shouldn't trigger retries from your side).
See Webhooks for the full handler pattern.
Distinguishing network from API errors
Most of the time you just want to log the failure and move on, in which case the base class is enough:
try:
plug.invoices.create(customer_id="cus_…", currency="IDR", lines=[…])
except PlugipayError as exc:
log.error("plugipay failed: status=%s code=%s req=%s", exc.status, exc.code, exc.request_id)
raise
But for retry logic you'll want to split:
PlugipayTimeoutError,PlugipayNetworkError→ transient. Retry with backoff.PlugipayErrorwithstatus >= 500→ transient. Retry with backoff.PlugipayErrorwithstatusin400-499→ permanent for this payload. Fix the input; don't retry the same request.
A clean split:
def is_retryable(exc: PlugipayError) -> bool:
if isinstance(exc, (PlugipayTimeoutError, PlugipayNetworkError)):
return True
return exc.status >= 500
Inspecting validation errors
When the API returns 400 invalid_request because of a bad payload, message contains a human-readable summary — usually pointing at the offending field:
try:
plug.customers.create(email="not-an-email")
except PlugipayError as exc:
if exc.code == "invalid_request":
print(exc.message)
# "email: must be a valid email address"
If you need structured per-field errors, hit the API directly — the SDK currently surfaces only the top-level error.message. Per-field error structures land in a future release; until then, parse the message or rely on client-side validation upstream.
Idempotency & safe retries
The SDK auto-attaches an Idempotency-Key header to every write method that creates a fresh resource (customers.create, checkout_sessions.create, invoices.pay, subscriptions.create, etc.). On retry, the same key on the same body returns the original response without re-creating.
The auto-generated key changes per call — meaning if your network blip happens between the SDK sending the request and you seeing the response, a naive retry would create a duplicate. The fix: pass your own idempotency key for high-stakes operations.
The SDK doesn't expose idempotency_key= as a kwarg on every method yet (tracked in the repo). For now, drop to the lower-level client.request() if you need explicit control:
plug.request(
method="POST",
path="/api/v1/checkout-sessions",
body={"amount": 250_000, "currency": "IDR", "methods": ["qris"], …},
idempotency_key="order-2026-05-12-001",
)
See Idempotency for the full contract.
Retry patterns
The SDK does not retry on its own — we treat that as application policy. Here's a minimal exponential-backoff wrapper for transient failures:
import time
import random
from plugipay import PlugipayError, PlugipayNetworkError, PlugipayTimeoutError
def with_retry(fn, *, attempts=3, base_delay=0.5):
"""Retry transient failures with jittered exponential backoff."""
for i in range(attempts):
try:
return fn()
except (PlugipayTimeoutError, PlugipayNetworkError) as exc:
if i == attempts - 1:
raise
sleep = base_delay * (2 ** i) + random.uniform(0, 0.25)
time.sleep(sleep)
except PlugipayError as exc:
if exc.status >= 500 and i < attempts - 1:
sleep = base_delay * (2 ** i) + random.uniform(0, 0.25)
time.sleep(sleep)
continue
raise
# Usage
session = with_retry(lambda: plug.checkout_sessions.create(
amount=125_000, currency="IDR", methods=["qris"],
success_url="…", cancel_url="…",
))
For production use, consider tenacity — same logic with cleaner ergonomics, retry-budgets, and observability:
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter
def is_transient(exc):
if isinstance(exc, (PlugipayTimeoutError, PlugipayNetworkError)):
return True
return isinstance(exc, PlugipayError) and exc.status >= 500
@retry(
retry=retry_if_exception(is_transient),
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(initial=0.5, max=4),
)
def create_session():
return plug.checkout_sessions.create(
amount=125_000, currency="IDR", methods=["qris"],
success_url="…", cancel_url="…",
)
Don't retry on
4xx. A 400/401/403/404/422 means the request is permanently wrong — retrying it as-is wastes API quota and triggers rate limits. Fix the payload (or credentials) first.
Logging the request id
When you file a support ticket, include request_id. It uniquely identifies the request on our side and is the fastest way for us to find what happened.
except PlugipayError as exc:
log.error(
"plugipay %s %s (req_id=%s)",
exc.code, exc.message, exc.request_id,
)
The request_id field is populated on every server response — success or failure. None only on client-side failures (timeout, network, bad signature) that never reached the server.
Common error codes
The full catalog is at API → Errors. The codes you'll see most often from the Python SDK:
status |
code |
What it means |
|---|---|---|
0 |
timeout |
Request exceeded timeout. Likely transient; retry. |
0 |
network_error |
DNS / connect / TLS / reset. Likely transient; retry. |
400 |
invalid_request |
Body or query is malformed. Fix the payload. |
401 |
invalid_signature |
The HMAC didn't verify. Almost always wrong secret. |
401 |
invalid_timestamp |
Your clock is more than 5 minutes off. |
403 |
insufficient_scope |
Key exists but lacks permission. Mint a wider-scoped key. |
404 |
not_found |
Resource doesn't exist, or belongs to a different workspace. |
409 |
idempotency_conflict |
Same Idempotency-Key, different body. |
429 |
rate_limited |
Too many requests. Back off. |
500-599 |
varies | Server-side issue. Retry with backoff. |
Next
- Pagination — safely iterate large lists.
- Webhooks — verify inbound deliveries.
- API → Errors — the full code catalog.
- Idempotency — safe retries on writes.