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.

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 touch 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 each type.
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ProblemDetail> handleUnauthorized(UnauthorizedException ex, HttpServletRequest req) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
    problem.setTitle("Unauthorized");
    problem.setDetail(ex.getMessage());
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(problem);
}

@ExceptionHandler(Exception.class) // catch-all → RFC 7807 ProblemDetail
public ResponseEntity<ProblemDetail> handleGeneric(Exception ex, HttpServletRequest req) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    problem.setTitle("Internal Server Error");
    problem.setDetail(ex.getMessage());
    return ResponseEntity.internalServerError().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 APIException.
use SentDm\Core\Exceptions\APIException;

try {
    $response = $this->client->messages->send(/* … */);
    // … map response …
} catch (APIException $e) {
    Log::error('Failed to send message', ['error' => $e->getMessage()]);
    return new MessageResult(success: false, error: $e->getMessage());
}
# app/services/sent_dm/base_service.rb — one place translates SDK errors.
def handle_api_error(error)
  Rails.logger.error "[SentDM] API Error: #{error.class} - #{error.message}"
  failure(:api_error, error.message)
end

# In a service:
#   rescue ApplicationService::ValidationError => e
#     failure(:validation_error, e.message)
#   rescue StandardError => e
#     handle_api_error(e)

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 60s timeout; tightening the timeout to something like 30s 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.
@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 Exception as exc:
        error_msg = str(exc)
        if "rate limit" in error_msg.lower() or "429" in error_msg:
            retry_after = 60 * (2 ** self.request.retries)   # exponential backoff
            raise RateLimitExceeded(retry_after=retry_after) from exc
        if "invalid" in error_msg.lower() or "400" in error_msg:
            raise NonRetryableError(f"Invalid request: {error_msg}", "INVALID_REQUEST") from exc
        if self.request.retries < self.max_retries:
            raise self.retry(exc=exc)   # transient → retry
        raise

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 30s 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 above in a queue worker with its own retry budget and idempotency keys.

Next steps

On this page