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-key reuse with a different body (idempotency_key_reuse). Other 409s — invalid_state (e.g. cancelling an already-terminal job) and worker_mismatch (heartbeat/ack from a worker that didn't claim the job) — surface through the same exception type but have distinct ErrorCode values you can match in a when clause. |
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. This is the HTTP transport layer — it retries individual API calls (e.g. a transient 503 while creating a job) before the exception ever reaches your code. It is not the same as the server-side job retry described in Retry Strategies, which retries ExecuteAsync failures after a job has already been enqueued. Some 429s may be retried by the transport layer; persistent 429s still surface as FlareRateLimitException once the resilience pipeline gives up.
Idempotency conflicts (409)
When you replay a request with an Idempotency-Key that's already in the 24-hour window and the request body differs from the original, the API returns 409 idempotency_key_reuse and the SDK throws FlareConflictException. A same-body replay does not 409 — it returns the original status code and body plus an Idempotent-Replay: true header (see Idempotency for the full state machine).
catch (FlareConflictException ex) when (ex.ErrorCode == "idempotency_key_reuse")
{
logger.LogWarning("Idempotency-Key reused with a different body — generate a new 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 /flare/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.ErrorMessage);
}
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) |
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-Key Reuse (idempotency_key_reuse) | No | Same key, different body — generate a new key or send the original body |
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, reports the failure to Flare, 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