Error Handling & Resilience

The SDK throws its own exception types. Your API shouldn't leak them. The pattern is the same everywhere: catch the SDK error in the service layer, translate it into one internal error type with a status code and a stable code, and let a single handler at the edge render it as JSON. Then layer on the resilience concerns (retries, rate limits, idempotency, timeouts), most of which the SDK already handles if you let it.

The SDK exception types are shown in all seven SDK languages below; the surrounding samples use a representative subset and transfer directly to the other supported SDK languages.

Pair this with the error-handling guide for the catalog of API error codes. This page is about structuring your integration to handle them.

One internal error type

Define a single ApiError (status code + machine code + message + optional details). Every service method catches the SDK exception and rethrows as this. The controllers never deal with it; one error handler renders it.

// types/index.ts
export class ApiError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly code: string,
    message: string,
    public readonly details?: Record<string, unknown>,
  ) {
    super(message);
    this.name = 'ApiError';
  }
}
# exceptions.py
class SentServiceException(Exception):
    def __init__(self, message: str, status_code: int = 500):
        self.message = message
        self.status_code = status_code
        super().__init__(self.message)
// internal/models — a single HTTP-shaped error the edge can render.
type HTTPError struct {
    Code      int    `json:"-"`
    ErrorCode string `json:"code"`
    Message   string `json:"message"`
}

var ErrMissingAPIKey = &HTTPError{
    Code:      http.StatusUnauthorized,
    ErrorCode: "UNAUTHORIZED",
    Message:   "Missing API key — send it as Authorization: Bearer <key>.",
}

Catch specific SDK exception types

Both the TypeScript and Python SDKs expose the same hierarchy: a base APIError with typed subclasses per status: BadRequestError (400), AuthenticationError (401), NotFoundError (404), RateLimitError (429), and a timeout error (APIConnectionTimeoutError in TypeScript, APITimeoutError in Python). Catch what you can act on differently; fall through to the base for everything else. These are real SDK-exported types, not app code. See each language's "Error handling" section in the SDK reference for the full, authoritative hierarchy (Go, Java, C#, PHP, and Ruby each expose their own idiomatic shape, shown in the tabs below).

// services/sent.service.ts — translate the SDK error, don't leak it.
import { APIError as SentApiError } from '@sentdm/sentdm';

private toApiError(error: unknown, fallbackMessage: string): ApiError {
  this.logger.error({ error }, fallbackMessage);
  if (error instanceof SentApiError) {
    const e = error as { status?: number; name: string; message: string; headers?: unknown };
    // e.name is BadRequestError | AuthenticationError | RateLimitError | …
    return new ApiError(e.status || 500, e.name, e.message, {
      headers: e.headers as Record<string, unknown> | undefined,
    });
  }
  return new ApiError(500, 'InternalError', fallbackMessage);
}
# exceptions.py — one handler maps every SDK APIError to your envelope.
from sent_dm import APIError

async def sent_api_exception_handler(request: Request, exc: APIError) -> JSONResponse:
    # The Python SDK exposes `status_code` (not `status`); the class name is the label.
    status_code = {400: 400, 401: 401, 403: 403, 404: 404, 422: 422, 429: 429}.get(
        getattr(exc, "status_code", 500), 500
    )
    return JSONResponse(
        status_code=status_code,
        content={
            "error": type(exc).__name__,
            "message": str(exc),
            "status_code": status_code,
        },
    )

# For per-call handling, catch the typed subclasses:
#   from sent_dm import BadRequestError, AuthenticationError, RateLimitError, APIError
// The SDK returns a typed *sentdm.Error; unwrap it to read StatusCode.
result, err := client.Messages.Send(ctx, params)
if err != nil {
    var apiErr *sentdm.Error
    if errors.As(err, &apiErr) {
        switch apiErr.StatusCode {
        case http.StatusTooManyRequests:
            return &models.HTTPError{Code: 429, ErrorCode: "RATE_LIMITED", Message: apiErr.Error()}
        case http.StatusUnauthorized:
            return &models.HTTPError{Code: 401, ErrorCode: "UNAUTHORIZED", Message: apiErr.Error()}
        }
    }
    return fmt.Errorf("failed to send message: %w", err)
}
// exception/GlobalExceptionHandler.java — @RestControllerAdvice maps the
// SDK's exported types (dm.sent.errors.*) to RFC 7807 ProblemDetail.
import dm.sent.errors.RateLimitException;
import dm.sent.errors.SentException;

@ExceptionHandler(RateLimitException.class) // SDK 429 — act on it differently
public ResponseEntity<ProblemDetail> handleRateLimit(RateLimitException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.TOO_MANY_REQUESTS);
    problem.setTitle("Rate Limited");
    problem.setDetail(ex.getMessage());
    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(problem);
}

@ExceptionHandler(SentException.class) // fall through to the SDK base type
public ResponseEntity<ProblemDetail> handleSentException(SentException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY);
    problem.setTitle("Messaging Provider Error");
    problem.setDetail(ex.getMessage());
    return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(problem);
}
// Middleware/GlobalExceptionMiddleware.cs — map SentApiException → status.
private static (int Status, string Title, string Detail) MapSdkException(SentApiException ex)
{
    var status = ex.StatusCode switch
    {
        HttpStatusCode.Unauthorized       => StatusCodes.Status401Unauthorized,
        HttpStatusCode.NotFound           => StatusCodes.Status404NotFound,
        HttpStatusCode.Conflict           => StatusCodes.Status409Conflict,
        HttpStatusCode.UnprocessableEntity=> StatusCodes.Status422UnprocessableEntity,
        HttpStatusCode.TooManyRequests    => StatusCodes.Status429TooManyRequests,
        _                                 => StatusCodes.Status502BadGateway,
    };
    return (status, "Sent DM API Error", ex.Message);
}
// app/Services/SentDM/SentDMService.php — catch the SDK's typed exceptions,
// rethrow your one internal error type; the edge handler renders it as JSON.
use SentDm\Core\Exceptions\APIException;
use SentDm\Core\Exceptions\BadRequestException;
use SentDm\Core\Exceptions\RateLimitException;

try {
    $response = $this->client->messages->send(/* … */);
    // … map response …
} catch (RateLimitException $e) {
    throw new ApiError(429, 'RATE_LIMITED', $e->getMessage());    // SDK 429
} catch (BadRequestException $e) {
    throw new ApiError(400, 'INVALID_REQUEST', $e->getMessage()); // SDK 400
} catch (APIException $e) {
    Log::error('Sent API call failed', ['error' => $e->getMessage()]);
    throw new ApiError(502, 'UPSTREAM_ERROR', $e->getMessage());  // SDK base
}
# app/services/sent_dm/base_service.rb — rescue the SDK's typed errors
# (Sentdm::Errors::*), rethrow your one internal error type.
def send_message(params)
  # … client.messages.send(**params) …
rescue Sentdm::Errors::RateLimitError => e
  raise ApiError.new(status: 429, code: "RATE_LIMITED", message: e.message)
rescue Sentdm::Errors::BadRequestError => e
  raise ApiError.new(status: 400, code: "INVALID_REQUEST", message: e.message)
rescue Sentdm::Errors::APIError => e # fall through to the SDK base type
  Rails.logger.error "[SentDM] #{e.class}: #{e.message}"
  raise ApiError.new(status: 502, code: "UPSTREAM_ERROR", message: e.message)
end

# One edge handler renders it: `rescue_from ApiError` in ApplicationController.

Validation errors are yours, not the SDK's

Reject malformed input before it reaches the SDK. Validate the body against a schema and emit a 400/422 with field-level detail. This keeps garbage off the wire and gives clients actionable errors.

// middleware/validate.ts
if (error instanceof ZodError) {
  const details = error.errors.map((e) => ({ path: e.path.join('.'), message: e.message }));
  next(new ApiError(400, 'ValidationError', 'Request validation failed', { errors: details }));
}

Retries and exponential backoff

The SDKs retry transient failures automatically. Connection errors, timeouts, and 429/5xx responses are retried with exponential backoff out of the box. You configure the ceiling, not the loop.

Set maxRetries (and a request timeout) when you build the client. The SDK defaults to 2 retries with a 60 s timeout; tightening the timeout to something like 30 s is a reasonable choice for a user-facing request path where you'd rather fail fast and let your own retry/queue logic take over.

// lib/sent/client.ts
export function clientForApiKey(apiKey: string): SentDm {
  return new SentDm({
    apiKey,
    maxRetries: 2,
    timeout: 30 * 1000,
  });
}
// SentClientFactory.php — maxRetries is set via requestOptions (default 2).
return new Client(
    apiKey: $apiKey,
    requestOptions: ['maxRetries' => $this->maxRetries],
);
// Per-call deadline; the SDK handles retry of transient failures internally.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
response, err := c.client.Messages.Send(ctx, params)

For long-running or bulk work, push sends onto a queue and let the worker own the retry policy. A Celery task, for example, can retry up to 3 times with backoff and classify errors as retryable vs terminal:

A queue worker has no inbound HTTP request to pull a bearer key from, so the per-request rule still applies. It's just the request that moved. Enqueue the caller's API key (or a reference to it) as task payload alongside the send parameters, and build sent_client from that payload inside the task, never from a boot-time singleton or a worker-wide env var.

This is the one deliberate exception to "never persist the key" (see Authentication): the payload sits in the broker for as long as the job is queued. Treat it accordingly: use a broker that encrypts at rest, keep task payloads out of any queue-monitoring dashboard or DLQ viewer that isn't itself access-controlled, and set a short result/message TTL so failed jobs don't retain the key indefinitely. If a tenant's key rotates, in-flight jobs enqueued with the old key will still use it until they drain; size your retry window and TTL accordingly.

# tasks/messages.py — api_key arrives as task payload, not a worker singleton.
from sent_dm import APIError, BadRequestError, RateLimitError, Sent, UnprocessableEntityError

@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_single_message(self, api_key, phone_number, template_id, variables=None, channel=None, **_):
    try:
        sent_client = Sent(api_key=api_key)  # built from this task's payload, per invocation
        result = sent_client.messages.send(to=[phone_number], template={...}, channel=channel)
        # …
    except RateLimitError as exc:
        retry_after = 60 * (2 ** self.request.retries)    # exponential backoff
        raise self.retry(exc=exc, countdown=retry_after)  # 429 → back off, then retry
    except (BadRequestError, UnprocessableEntityError) as exc:
        raise NonRetryableError(f"Invalid request: {exc}", "INVALID_REQUEST") from exc  # terminal
    except APIError as exc:
        raise self.retry(exc=exc)  # transient (timeout, 5xx) → bounded retry, then surface

Only retry transient failures (timeouts, 429, 5xx). Never blindly retry a 400/422. The request is malformed and will fail every time. Retrying a bad request just amplifies the error and burns rate-limit budget.

Rate limits and Retry-After

A 429 (RateLimitError) means back off. The SDK's built-in retry already honors the Retry-After header for you. If you surface the 429 to your own client, for example from a queue worker that has exhausted its retries, propagate a Retry-After so they can back off too, using the same exponential schedule (60 * 2^attempt).

Idempotency for sends

Retries (yours or the SDK's) mean a send can be attempted more than once. Guard against duplicate deliveries with an idempotency key: a stable key derived from the business event (order id, notification id), stored before you call send. If a retry comes in for a key you've already sent, short-circuit and return the recorded result instead of sending again. This is the outbound mirror of the inbound dedupe you do on message_id + message_status (not X-Webhook-ID; that's the endpoint id, identical on every delivery) in Status tracking.

This key needs the same shared, persistent store as the message-status store. An in-memory map has the identical failure mode: it's lost on restart, and a retry that lands on a different instance than the original attempt won't see it, so you send twice anyway. A database unique constraint on the idempotency key, or Redis with a TTL long enough to cover your retry window, both work. See Idempotency across instances.

Timeouts

Always bound the call: a 30 s timeout is a reasonable default; in Go, wrap every SDK call in a context.WithTimeout(ctx, 30*time.Second). A timeout surfaces as a retryable connection error (APIConnectionTimeoutError in TypeScript, APITimeoutError in Python), so the SDK's retry logic picks it up first, until retries are exhausted, at which point it surfaces as the timeout error your handler maps.

The whole chain

  1. Validate input → 400/422 before the SDK sees it.
  2. Call the SDK with a bounded timeout and a maxRetries cap.
  3. The SDK retries transient failures (timeouts, 429, 5xx) with backoff, honoring Retry-After.
  4. Catch the typed SDK exception in the service; translate to your ApiError.
  5. Render it once at the edge as consistent JSON.
  6. For bulk/async work, do all of the preceding in a queue worker with its own retry budget and idempotency keys.

Next steps

On this page