Errors
The Plugipay Go SDK has one error type: *plugipay.Error. Every failure mode — network timeout, DNS, bad credentials, 4xx, 5xx, signature failure on a webhook — comes back as this type, wrapped through the standard error interface. You branch on the stable .Code field, not on string matching the message.
var pe *plugipay.Error
if errors.As(err, &pe) {
switch pe.Code {
case "rate_limited":
// back off and retry
case "timeout", "network_error":
// transport-level — also retryable
case "invalid_request":
// client bug — surface to logs
}
}
The type
type Error struct {
Status int // HTTP status code; 0 for transport failures
Code string // stable, e.g. "rate_limited", "signature_invalid"
Message string // human-readable
RequestID string // from response meta, when present
}
func (e *Error) Error() string // satisfies error
Four fields, all you need:
Status— the HTTP code from the response, or0if the request never got that far (DNS failure, connection refused, context canceled).Code— the value to branch on. Stable across SDK versions; we'll never silently rename one.Message— safe to surface to operators; not safe to surface to end users without context (it can leak request shape).RequestID— correlates to server logs. Always log this when reporting bugs.
Error() formats them in a useful way:
plugipay: rate_limited: too many requests (status=429, requestId=req_01HXX...)
plugipay: timeout: plugipay request timed out (timeout=30s)
plugipay: network_error: dial tcp: lookup plugipay.com: no such host
Extracting with errors.As
This is the idiomatic Go pattern. Don't type-assert directly — the SDK wraps in some paths and unwrapping is easier with errors.As:
import "errors"
cust, err := c.Customers.Create(ctx, in)
if err != nil {
var pe *plugipay.Error
if errors.As(err, &pe) {
log.Printf("plugipay failed: code=%s status=%d requestId=%s",
pe.Code, pe.Status, pe.RequestID)
} else {
// Not a plugipay error at all — programmer bug, or context error
// unwrapped before it reached the SDK.
log.Printf("unexpected error: %v", err)
}
return err
}
errors.As populates pe and returns true if any error in the chain is *plugipay.Error. It also satisfies linters that warn against bare type assertions.
Categories of errors
Transport failures (Status == 0)
The request never reached Plugipay, or never got a response back. The Code distinguishes the cause:
Code |
Meaning |
|---|---|
network_error |
DNS, TLS, connection refused, socket reset |
timeout |
the *http.Client.Timeout (or context deadline) fired |
canceled |
ctx was canceled by the caller before completion |
invalid_request |
the SDK couldn't build the *http.Request (rare; programmer bug) |
serialize_failed |
json.Marshal of the request body failed |
These are almost always retryable. Back off and try again.
Response failures (Status >= 400)
Plugipay returned an HTTP error. Code is sourced from the server's response envelope, e.g.:
Code |
Status |
Meaning |
|---|---|---|
invalid_signature |
401 | HMAC signature didn't match. Almost always wrong secret. |
invalid_key |
401 | Key ID doesn't exist or is revoked. |
invalid_timestamp |
401 | Clock skew > 5 minutes. Sync your system clock. |
mode_mismatch |
401 | Test key against live endpoint or vice versa. |
insufficient_scope |
403 | Key lacks permission for this operation. |
not_found |
404 | Resource id doesn't exist. |
validation_error |
422 | Request body failed schema validation. |
rate_limited |
429 | Too many requests. Back off. |
server_error |
500 | Plugipay bug. Retry; if persistent, report with RequestID. |
Full catalog: API → Errors.
Response-decode failures
Rare, but possible if a proxy mangles the response:
Code |
Meaning |
|---|---|
invalid_response |
response body was not valid JSON or didn't fit the expected envelope |
Includes a snippet of the bad body in Message.
Distinguishing network from API errors
A common need: retry transport failures unconditionally, retry 5xx with backoff, surface 4xx to the user.
func classify(err error) string {
var pe *plugipay.Error
if !errors.As(err, &pe) {
return "unknown"
}
switch {
case pe.Status == 0:
return "transport" // network_error, timeout, canceled
case pe.Status == 429:
return "rate_limited"
case pe.Status >= 500:
return "server"
case pe.Status >= 400:
return "client"
default:
return "ok"
}
}
Context cancellation
If the caller cancels the context, the SDK aborts the in-flight call promptly. You see one of:
*plugipay.Error{Status: 0, Code: "canceled", Message: "request canceled"}
*plugipay.Error{Status: 0, Code: "timeout", Message: "plugipay request timed out (timeout=30s)"}
context.Canceled and context.DeadlineExceeded are mapped to these codes so you don't have to do errors.Is(err, context.DeadlineExceeded) separately. If you want both forms (canonical Go context errors + the SDK code), call sites can:
if errors.Is(ctx.Err(), context.Canceled) {
// caller bailed; don't log loudly
}
ctx.Err() is independent of the SDK's wrapping and always reflects the context state.
Retry recipe
A practical retry loop for idempotent operations (GET, or any POST that the SDK auto-keys with Idempotency-Key):
func withRetries[T any](ctx context.Context, op func(context.Context) (T, error)) (T, error) {
var zero T
backoff := 200 * time.Millisecond
const maxAttempts = 5
for attempt := 0; attempt < maxAttempts; attempt++ {
result, err := op(ctx)
if err == nil {
return result, nil
}
var pe *plugipay.Error
if !errors.As(err, &pe) {
return zero, err
}
retryable := pe.Status == 0 || // transport (timeout, network)
pe.Status == 429 || // rate_limited
pe.Status >= 500 // server_error
if !retryable {
return zero, err
}
// honor context cancellation between attempts
select {
case <-ctx.Done():
return zero, ctx.Err()
case <-time.After(backoff):
}
backoff *= 2
}
return zero, errors.New("plugipay: retries exhausted")
}
// usage:
inv, err := withRetries(ctx, func(ctx context.Context) (*plugipay.Invoice, error) {
return c.Invoices.Get(ctx, id)
})
Three things this does right:
- Type-checks the error before retrying. Never retries on a programmer bug.
- Respects context cancellation between attempts — if the caller bails, we stop sleeping.
- Exponential backoff — 200ms, 400ms, 800ms, 1.6s, 3.2s. Stops at 5 attempts.
For non-idempotent writes (a Create without an explicit Idempotency-Key), be more cautious — retrying a 5xx might double-charge. The SDK auto-generates idempotency keys for Create calls; that key is per call, so a retry of the same Go statement will mint a new key and the server treats it as a separate request. If you want retry safety, capture the key yourself via the low-level c.Do(...) and reuse it across retries.
Webhook errors
The webhook helpers (VerifyWebhook / VerifyWebhookSignature) return their own *plugipay.Error codes:
Code |
Meaning |
|---|---|
signature_missing |
X-Plugipay-Signature header was empty, or the secret was empty |
signature_malformed |
header parsed but missing t= or v1=, or v1 wasn't hex |
signature_stale |
timestamp older (or newer) than 300s tolerance |
signature_invalid |
signature didn't match |
invalid_payload |
body wasn't valid JSON |
See Webhooks for the verification flow.
Logging guidance
A reasonable structured-log shape on error:
slog.Error("plugipay call failed",
"code", pe.Code,
"status", pe.Status,
"requestId", pe.RequestID,
"endpoint", "Customers.Create",
)
Three rules:
- Log
CodeandRequestID. Both are stable; both help support correlate. - Don't log
Messageto user-facing channels — it can leak request shape. - Don't log
Secret. The SDK doesn't expose it anyway.
Next
- API reference → Errors — the full catalog of error codes.
- Pagination — iterating
Page[T]. - Webhooks — verification and signature errors.