Error recovery

Handle failed jobs, transient errors, and submission failures

This page covers how to handle errors at each stage of a moderation request: submission, processing, and result retrieval.

Error types#

Stage What can go wrong Recovery
Submission 4xx validation error Fix the request and resubmit
Submission 5xx server error Retry with exponential backoff
Processing Job reaches failed status Resubmit the original request
Result retrieval 404 JOB_NOT_FOUND Check the job ID; the job may belong to a different account
Result retrieval 401 UNAUTHORIZED Refresh or verify your API key

Transient vs permanent errors#

Transient errors are temporary and safe to retry:

  • 429 RATE_LIMITED: wait and retry (see rate limiting)
  • 500 INTERNAL_ERROR: server-side failure; retry with backoff
  • 503: service degraded; retry with backoff

Permanent errors should not be retried without changing something:

  • 400 INVALID_REQUEST: fix the request (missing fields, invalid file format, file too large)
  • 401 UNAUTHORIZED: provide a valid API key
  • 403 ACCOUNT_TERMINATED: contact support; the account cannot be recovered programmatically

Retrying submissions#

For 5xx errors, use exponential backoff to avoid overwhelming the API during an outage.

JavaScript
async function submitWithRetry(formData, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const response = await fetch('https://api.omnifence.ai/api/v1/moderate/image', {
      method: 'POST',
      headers: { Authorization: `Bearer ${process.env.OMNIFENCE_API_KEY}` },
      body: formData,
    });

    if (response.ok) return response.json();

    const error = await response.json();

    // Don't retry permanent errors
    if (response.status < 500) throw new Error(error.message);

    // Exponential backoff for server errors
    if (attempt < maxAttempts) {
      await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
    }
  }

  throw new Error('Max retry attempts reached');
}

Handling failed jobs#

A job with status: "failed" means processing encountered an unrecoverable error after the job was accepted. is_prohibited will be null.

JSON
{
  "is_prohibited": null,
  "error_code": "MODEL_UNAVAILABLE",
  "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "type": "image",
  "status": "failed",
  "created_at": "2026-03-24T12:00:00.000Z",
  "completed_at": "2026-03-24T12:00:10.000Z"
}

error_code is a diagnostic hint, present only on some failures. A job that completes is a full success and has no error_code, also when it completed after an internal retry. Treat it as optional and never branch on it being there. Seven values are worth handling:

  • PROVIDER_RATE_LIMITED: the AI provider rate-limited the request and the limit did not clear within the retry window. Resubmit after a short delay.
  • PROVIDER_UNAVAILABLE: the AI provider returned server errors, or could not fetch a reachable media URL, on every attempt. Resubmit after a short delay.
  • PROVIDER_TIMEOUT: the AI provider did not answer within the time allowed, on every attempt. Resubmit after a short delay.
  • MODEL_UNAVAILABLE: the configured model is not served for this input. Resubmit after a short delay.
  • MODEL_REFUSED: the moderation service refused this exact input. An identical resubmission returns the same result, so change the input or handle the job as undecidable.
  • MEDIA_UNREACHABLE: the media URL submitted with the job did not resolve when fetched (for example HTTP 404, or an expired signed link). Resubmitting the same URL fails the same way. Fix the URL, then resubmit.
  • UNSUPPORTED_MEDIA: the provider fetched the media but could not open it as an image or a video (for example a video file submitted as an image, a streaming manifest or an unreadable container that the submit-time probe could not tell apart from a video file), or could not sample any frames from it (for example a file that holds a single still image over a long audio track), or the video URL serves an untyped Content-Type such as application/octet-stream that the video model will not fetch. For audio, the file is not in a supported audio format, has no audio track, or cannot be read. Resubmitting the same URL fails the same way. Submit a direct link to a supported image or audio file, or to an MP4, WebM or QuickTime file that contains moving video and is served with a video Content-Type.

Failed jobs should be resubmitted as new requests. The original job_id cannot be retried. If the job carried a webhook_url, the same error_code arrives on the failed webhook.

Rate limit recovery#

When you receive a 429 RATE_LIMITED response, the error body includes details:

JSON
{
  "error": "RATE_LIMITED",
  "message": "Rate limit exceeded",
  "statusCode": 429
}

Wait before retrying. The standard rate limit window is 60 seconds. See rate limiting for limits by tier.

Checking a text moderation batch#

A text moderation batch sends one webhook for all its items. If that webhook is missed, read the batch once instead of polling each item:

JavaScript
const response = await fetch(`https://api.omnifence.ai/api/v1/moderate/text/batch/${batchId}`, {
  headers: { Authorization: `Bearer ${process.env.OMNIFENCE_API_KEY}` },
});
const batch = await response.json();
if (batch.status === 'completed') {
  for (const item of batch.items) {
    // item.key, item.is_prohibited, item.reason, item.error_code
    // item.scores and item.custom_scores are optional and informational
  }
}

The batch status is completed once every item is completed or failed. One read counts as one request against your rate limit.

Checking job status after a timeout#

If your application restarts or a webhook is missed, poll the job status endpoint to recover in-flight jobs:

JavaScript
async function pollUntilComplete(jobId, timeoutMs = 300_000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const response = await fetch(`https://api.omnifence.ai/api/v1/job/${jobId}`, {
      headers: { Authorization: `Bearer ${process.env.OMNIFENCE_API_KEY}` },
    });
    const job = await response.json();

    if (job.status === 'completed' || job.status === 'failed') return job;

    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
  throw new Error(`Job ${jobId} did not complete within timeout`);
}