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
raiseOnly 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
- 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 above in a queue worker with its own retry budget and idempotency keys.
Next steps
Contacts & Templates
Service wrappers for contacts and templates — the camelCase contract mapping, looking up templates by id or name, and keeping your app decoupled from hard-coded IDs.
The Webhook Receiver
Build the inbound endpoint end-to-end — capture the raw body, verify, acknowledge fast with a 2xx, then process asynchronously. The framework-specific raw-body plumbing and the response contract that keeps Sent from retrying.