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 surfaceOnly 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
- Validate input →
400/422before the SDK sees it. - Call the SDK with a bounded timeout and a
maxRetriescap. - The SDK retries transient failures (timeouts,
429,5xx) with backoff, honoringRetry-After. - Catch the typed SDK exception in the service; translate to your
ApiError. - Render it once at the edge as consistent JSON.
- For bulk/async work, do all of the preceding in a queue worker with its own retry budget and idempotency keys.
Next steps
Managing Contacts & Templates
Service wrappers for contacts and templates: the camelCase contract mapping, looking up templates by id or name, and decoupling your app from hard-coded IDs.
Building the Webhook Receiver
Build the inbound webhook endpoint end-to-end: capture the raw body, verify the signature, acknowledge fast with a 2xx, and process events asynchronously.