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, or 0 if 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:

  1. Type-checks the error before retrying. Never retries on a programmer bug.
  2. Respects context cancellation between attempts — if the caller bails, we stop sleeping.
  3. 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 Code and RequestID. Both are stable; both help support correlate.
  • Don't log Message to user-facing channels — it can leak request shape.
  • Don't log Secret. The SDK doesn't expose it anyway.

Next

Plugipay — Payments that don't tax your success