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.
| Exception | HTTP | When |
|---|---|---|
FlareApiException | any | Base class — catch-all for unexpected status codes (400, 500, etc.) |
FlareAuthenticationException | 401 | Invalid, revoked, or missing API key |
FlareNotFoundException | 404 | Job or resource does not exist |
FlareConflictException | 409 | Idempotency conflict, invalid state transition, or worker mismatch |
FlareRateLimitException | 429 | Rate limit exceeded — includes Limit, Remaining, and ResetAt |
See the Exception Hierarchy reference for full property documentation.
Recommended catch pattern
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);
}
}
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.
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.
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:
| Error | Retryable? | Action |
|---|---|---|
500 Internal Server Error | Yes | Retry with backoff (SDK does this automatically via Polly) |
429 Rate Limit Exceeded | Yes | Wait until ResetAt, then retry |
408 / timeout | Yes | Retry with backoff |
401 Unauthorized | No | Fix your API key configuration |
404 Not Found | No | The resource does not exist — check the ID |
409 Idempotency Conflict | No | The job already exists — this is success |
409 Invalid State | No | The operation is not valid for the current job state |
400 Validation Error | No | Fix 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
- Retry Strategies — exponential backoff, MaxAttempts configuration, dead letter behavior
- Idempotency — preventing duplicate work with idempotency keys
- Exception Hierarchy — full SDK exception reference
- Error Responses — API error codes and validation rules