Skip to content
SearchJet
Updated October 1, 2026

Error Handling

SearchJet reports errors as HTTP status codes with a JSON body. The body's shape varies by endpoint, so your error handling should check several fields. This page lists the errors you're likely to meet, says which ones are worth retrying, and gives backoff code for JavaScript and Laravel.

Always send Accept: application/json

Without this header:

  • a rate-limit 429 comes back as an HTML page.
  • validation errors on /index/bulk, /index/delete and /ai/generate come back as a 302 redirect instead of a 422 JSON response.

Send Accept: application/json on every request.

Error body shapes

Where Shape Example
/search {"error", "code"} {"error": "Invalid or expired API key.", "code": "INVALID_API_KEY"}
/index/* {"success": false, "error", "message"} {"success": false, "error": "quota_exceeded", "message": "Document limit exceeded. ..."}
Authentication middleware (private key) {"message"} {"message": "Invalid or expired token"}
Plan rate limits {"error", "message", ...} {"error": "Rate limit exceeded", "message": "Your plan allows 10 requests per minute. ..."}
IP rate limit {"message"} {"message": "Too Many Attempts."}
Validation (index, AI) 422 {"message", "errors"} Field-by-field messages
Validation (analytics) 400 {"error": "Validation failed", "messages"} Field-by-field messages
Unexpected 500 {"message": "Server Error"}

To get one error identifier from any of these shapes, read body.code ?? body.error ?? body.message.

Common errors

Status Identifier Cause Fix Retry?
400 MISSING_QUERY No q on /search Send a query No
400 INVALID_FILTER Bad syntax, a field your plan can't filter on, or an attribute that isn't filterable Fix the filter or the index settings No
400 INVALID_SORT Sorting on an unsupported attribute Use a sortable attribute No
401 MISSING_API_KEY, INVALID_API_KEY, Invalid or expired token Missing, wrong or revoked key, or the wrong key type Use the public key for search and the private key for everything else No
403 SEMANTIC_PLAN_REQUIRED, SEMANTIC_NOT_ENABLED semantic=true without Pro, or without semantic search enabled for the site Upgrade, or enable it in site settings No
413 payload_too_large A document is over 1 MB Shrink content No
429 Too Many Attempts., Rate limit exceeded, Burst limit exceeded Too many requests Back off Yes
429 QUOTA_EXCEEDED (search), quota_exceeded (index), reindex_limit, limit_exceeded (AI) Plan quota used up Wait for the reset, or upgrade No
429 Abuse detected Bulk indexing too fast (Free and Pro) Slow down. Retrying prolongs the block. No
500 MEILISEARCH_ERROR, bulk_failed, Server Error Engine or server error. MEILISEARCH_ERROR can also mean you requested facets on an attribute that isn't facetable. Check the request, then retry Yes, a few times

Indexing is asynchronous, so some problems, such as an invalid character in a document id, only show up when the task runs. Check GET /index/tasks/{taskUid} for status: "failed" and read the error it reports.

401 checklist

  • The header must be Authorization: Bearer . Keys in the URL aren't accepted.
  • Use the public key for /search. The private key returns INVALID_API_KEY there.
  • Use the private key for /index/*, analytics and AI. The public key returns Invalid or expired token.
  • In Laravel, run php artisan config:clear after you edit .env.

See API Key Setup.

Backoff rules

Retry only rate-limit 429s, 5xx responses and network errors. Wait about 1, 2, 4 and then 8 seconds, adding some random jitter, then give up. If the response has a Retry-After header, wait at least that long. IP rate-limit 429s include Retry-After, but per-key plan-limit 429s don't.

Retry in JavaScript

const NO_RETRY = new Set(['QUOTA_EXCEEDED', 'quota_exceeded', 'reindex_limit', 'limit_exceeded', 'Abuse detected']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

class SearchJetError extends Error {
  constructor(status, body) {
    const id = body?.code ?? body?.error ?? body?.message ?? `HTTP ${status}`;
    super(body?.message && body?.message !== id ? `${id}: ${body.message}` : id);
    this.status = status;
    this.id = id;
  }
}

async function searchjetFetch(url, options = {}, maxRetries = 4) {
  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      res = await fetch(url, {
        ...options,
        headers: { Accept: 'application/json', ...options.headers },
        signal: AbortSignal.timeout(10000),
      });
    } catch (networkError) {
      if (attempt >= maxRetries) throw networkError;
      await sleep(1000 * 2 ** attempt + Math.random() * 250);
      continue;
    }

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

    const body = await res.json().catch(() => null);
    const err = new SearchJetError(res.status, body);
    const retryable = (res.status === 429 && !NO_RETRY.has(err.id)) || res.status >= 500;
    if (!retryable || attempt >= maxRetries) throw err;

    const retryAfter = Number(res.headers.get('Retry-After'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt + Math.random() * 250);
  }
}

// Usage (browser, public key)
try {
  const data = await searchjetFetch(
    `https://app.searchjetengine.com/api/v1/search?${new URLSearchParams({ q: 'react tutorial' })}`,
    { headers: { Authorization: 'Bearer YOUR_PUBLIC_KEY' } }
  );
  render(data.results);
} catch (err) {
  if (err.id === 'QUOTA_EXCEEDED') showMessage('Search is temporarily unavailable.');
  else console.error('Search failed:', err.message);
}

Retry in Laravel

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

function searchjetErrorId($response): ?string
{
    return $response->json('code') ?? $response->json('error') ?? $response->json('message');
}

function searchjetSearch(array $params): array
{
    $noRetry = ['QUOTA_EXCEEDED', 'quota_exceeded', 'reindex_limit', 'limit_exceeded', 'Abuse detected'];

    try {
        return Http::withToken(config('services.searchjet.public_key'))
            ->acceptJson()
            ->timeout(10)
            ->retry([1000, 2000, 4000], when: function ($e) use ($noRetry) {
                if ($e instanceof ConnectionException) {
                    return true;
                }
                if (! $e instanceof RequestException) {
                    return false;
                }
                $status = $e->response->status();
                return $e->response->serverError()
                    || ($status === 429 && ! in_array(searchjetErrorId($e->response), $noRetry, true));
            })
            ->get('https://app.searchjetengine.com/api/v1/search', $params)
            ->throw()
            ->json();
    } catch (RequestException $e) {
        Log::warning('SearchJet search failed', [
            'status' => $e->response->status(),
            'error' => searchjetErrorId($e->response),
        ]);
        return ['results' => [], 'total' => 0];
    } catch (ConnectionException $e) {
        Log::warning('SearchJet unreachable', ['message' => $e->getMessage()]);
        return ['results' => [], 'total' => 0];
    }
}

For indexing jobs, set $tries and $backoff on the queued job and let the queue retry. Fail the job straight away ($this->fail($e)) on Abuse detected and the quota errors, because retrying them doesn't help. A full example is in Step-by-Step Installation & Configuration.

Best practices

  1. Send Accept: application/json on every request.
  2. Read code, then error, then message to identify an error.
  3. Retry only rate limits, 5xx responses and network errors, with backoff and a retry cap.
  4. Never retry Abuse detected or quota errors.
  5. Set timeouts, and show a fallback (an empty state or cached results) instead of a broken page.

See also

Was this article helpful?

Ready to add search to your project?

Start free. No credit card required. Deploy in under 10 minutes.