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
429comes back as an HTML page. - validation errors on
/index/bulk,/index/deleteand/ai/generatecome back as a302redirect instead of a422JSON 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 returnsINVALID_API_KEYthere. - Use the private key for
/index/*, analytics and AI. The public key returnsInvalid or expired token. - In Laravel, run
php artisan config:clearafter 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
- Send
Accept: application/jsonon every request. - Read
code, thenerror, thenmessageto identify an error. - Retry only rate limits, 5xx responses and network errors, with backoff and a retry cap.
- Never retry
Abuse detectedor quota errors. - Set timeouts, and show a fallback (an empty state or cached results) instead of a broken page.