Skip to main content

Error Handling

Every API call can fail — bad credentials, exceeded rate limits, duplicate idempotency keys, or transient server errors. The SDK maps each failure to a typed exception so you can handle them precisely.

Exception hierarchy

The SDK defines five exception types. Every exception carries the HTTP StatusCode, a machine-readable ErrorCode, and the server-assigned RequestId for support correlation.

ExceptionHTTPWhen
FlareApiExceptionanyBase class — catch-all for unexpected status codes (400, 500, etc.)
FlareAuthenticationException401Invalid, revoked, or missing API key
FlareNotFoundException404Job or resource does not exist
FlareConflictException409Idempotency conflict, invalid state transition, or worker mismatch
FlareRateLimitException429Rate limit exceeded — includes Limit, Remaining, and ResetAt

See the Exception Hierarchy reference for full property documentation.

Catch from most specific to least specific. In a real service you typically enqueue jobs from a request handler or domain service:

public class OrderService(IJobClient jobs, ILogger<OrderService> logger)
{
public async Task PlaceOrderAsync(Order order)
{
try
{
var jobId = await jobs.EnqueueAsync<ProcessOrder>(
new { order.Id, order.Total },
new JobOptions { IdempotencyKey = $"order:{order.Id}" });

logger.LogInformation("Enqueued order processing {JobId}", jobId);
}
catch (FlareRateLimitException ex)
{
logger.LogWarning("Rate limited, resets at {ResetAt}", ex.ResetAt);
// Queue locally or return 503 to the caller
}
catch (FlareConflictException)
{
logger.LogInformation("Order {OrderId} already enqueued (idempotency)", order.Id);
// Safe to ignore — the job already exists
}
catch (FlareAuthenticationException ex)
{
logger.LogCritical("API key invalid: {Error}", ex.Message);
throw; // Configuration error — fail fast
}
catch (FlareApiException ex)
{
logger.LogError(
"Flare API error {Status}: {Code} — {Message} (request: {RequestId})",
ex.StatusCode, ex.ErrorCode, ex.Message, ex.RequestId);
}
}
}

Rate limit errors (429)

When your project exceeds its hourly request quota, the API returns 429 and the SDK throws FlareRateLimitException. The exception exposes the rate limit window from response headers:

catch (FlareRateLimitException ex)
{
logger.LogWarning(
"Rate limited: {Limit} req/hour, {Remaining} left, resets at {ResetAt}",
ex.Limit, ex.Remaining, ex.ResetAt);

if (ex.ResetAt.HasValue)
{
var delay = ex.ResetAt.Value - DateTimeOffset.UtcNow;
if (delay > TimeSpan.Zero)
await Task.Delay(delay);
}
}
note

The SDK's HTTP client uses AddStandardResilienceHandler() from Microsoft.Extensions.Http.Resilience (Polly). Depending on the Polly configuration, transient failures (including some 429s) may be retried automatically before the exception reaches your code.

Idempotency conflicts (409)

When you enqueue a job with an IdempotencyKey that already exists, the API returns 409 idempotency_conflict and the SDK throws FlareConflictException. This is expected behavior, not an error — the original job already exists and will be processed.

catch (FlareConflictException ex) when (ex.ErrorCode == "idempotency_conflict")
{
logger.LogInformation("Duplicate prevented by idempotency key");
}

For a complete guide on designing idempotency keys, see Idempotency.

tip

CancelAsync and RetryAsync do not throw on 409 — they return false instead, making them safe to call without try/catch for state-conflict scenarios.

Dead letter inspection

When a job exhausts all retry attempts (AttemptNumber >= MaxAttempts), it moves to DeadLetter state. Dead-lettered jobs are not retried automatically — they require manual intervention.

Querying dead letter jobs

Use the list endpoint with a state filter:

GET /v1/jobs?state=dead_letter&limit=20

Or from the SDK:

var status = await jobs.GetStatusAsync(jobId);

if (status?.State == JobState.DeadLetter)
{
logger.LogError(
"Job {JobId} dead-lettered after {Attempts} attempts: {Error}",
jobId, status.AttemptNumber, status.Error?.Message);
}

Manual retry

Retry a dead-lettered job via the API or SDK. This resets the job to Pending, clears error data, and bumps MaxAttempts if the current count has already been reached:

var retried = await jobs.RetryAsync(deadLetteredJobId);

if (retried)
logger.LogInformation("Job {JobId} requeued from dead letter", deadLetteredJobId);
else
logger.LogWarning("Job {JobId} is not in a retryable state", deadLetteredJobId);

You can also retry jobs from the dashboard with the Retry button on the job detail page.

warning

When a job moves to DeadLetter, any child continuation jobs in Scheduled state are automatically cancelled. Retrying the parent does not restore those children — you would need to re-enqueue them.

Transient vs permanent failures

Not all errors are retryable. Use this table to decide how to handle each:

ErrorRetryable?Action
500 Internal Server ErrorYesRetry with backoff (SDK does this automatically via Polly)
429 Rate Limit ExceededYesWait until ResetAt, then retry
408 / timeoutYesRetry with backoff
401 UnauthorizedNoFix your API key configuration
404 Not FoundNoThe resource does not exist — check the ID
409 Idempotency ConflictNoThe job already exists — this is success
409 Invalid StateNoThe operation is not valid for the current job state
400 Validation ErrorNoFix the request payload

Error handling inside job execution

When your job's ExecuteAsync throws an unhandled exception, the worker catches it, sends status: "failed" to the API, and the retry engine takes over. You do not need to catch every exception — but there are patterns worth following:

Pass the cancellation token

Always pass ctx.CancellationToken to async operations so work stops promptly on timeout or shutdown:

public async Task ExecuteAsync(OrderPayload payload, JobContext ctx)
{
var response = await _httpClient.PostAsync(url, content, ctx.CancellationToken);
await _db.SaveChangesAsync(ctx.CancellationToken);
}

Log with context

Use ctx.Logger for structured logging scoped to the job execution:

public async Task ExecuteAsync(ReportPayload payload, JobContext ctx)
{
ctx.Logger.LogInformation(
"Processing report {ReportId}, attempt {Attempt}/{Max}",
payload.ReportId, ctx.AttemptNumber, ctx.MaxAttempts);

try
{
await GenerateReport(payload, ctx.CancellationToken);
}
catch (ExternalServiceException ex)
{
ctx.Logger.LogWarning(ex, "External service failed, will retry");
throw; // Let the retry engine handle it
}
}

Catch domain exceptions selectively

If a domain exception means the job should not be retried (e.g., invalid data that will never succeed), you can catch it and complete gracefully:

public async Task ExecuteAsync(ImportPayload payload, JobContext ctx)
{
try
{
await ImportRecords(payload, ctx.CancellationToken);
}
catch (InvalidDataException ex)
{
ctx.Logger.LogError(ex, "Import data is invalid, skipping retries");
// Return without throwing — the worker reports success
// Alternatively, throw a custom exception that you handle upstream
return;
}
}

See also