Errors & Rate Limits
Error codes, HTTP status codes, retry strategies, and rate limit headers for the AACsearch public API.
AACsearch maps all upstream errors to typed error codes before returning them to callers. Raw search engine error messages are never forwarded — this prevents information leakage and provides a stable error contract.
Error response format
All error responses follow this format:
{
"error": "error_code",
"message": "Human-readable description of the error",
"retryable": false,
"requestId": "req_01HX9JKP7QZK7M5T2N4F8R6Y3W",
"docsUrl": "https://docs.aacsearch.com/troubleshooting/auth-errors"
}| Field | Stable? | Use it for |
|---|---|---|
error | yes — machine-readable, stable across versions | switch/case in your error handler |
message | no — human-readable, may change between versions | display to engineers in dashboards/logs |
retryable | yes — boolean | wire into retry logic (do not parse the code yourself) |
requestId | yes — opaque ULID | pass to support to find the request in logs |
docsUrl | yes — present for documented errors | link from your error UI |
The requestId is also returned in the response header X-Request-Id for cases where the body cannot be parsed (e.g., 504 from an upstream proxy).
HTTP status codes and error codes
4xx — Client errors (do not retry automatically)
| HTTP | error code | Cause |
|---|---|---|
| 400 | invalid_request | Malformed JSON or missing required field |
| 400 | invalid_filter | Invalid filterBy expression syntax |
| 400 | invalid_sort | Invalid sortBy expression |
| 401 | unauthorized | Missing, invalid, or expired API key or scoped token |
| 401 | token_expired | Scoped token TTL has passed |
| 403 | forbidden | Key does not have required scope (search or connector_write) |
| 403 | origin_not_allowed | Request origin not in allowedOrigins for the key |
| 404 | index_not_found | The specified indexSlug does not exist for this organization |
| 429 | rate_limit_exceeded | Per-key rate limit exceeded |
| 429 | quota_exceeded | Monthly search-unit quota for the organization is exhausted |
5xx — Server errors (may retry with backoff)
| HTTP | error code | Cause |
|---|---|---|
| 502 | search_failed | Upstream search engine error (cluster unreachable, query timeout) |
| 502 | ingest_failed | Failed to enqueue documents to the ingest buffer |
| 503 | service_unavailable | AACsearch API is temporarily unavailable |
Rate limiting
Rate limiting is enforced per API key using a sliding-window counter.
Default limit: 60 requests per minute per key.
When the limit is exceeded, the response is:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 15
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1717201234
{
"error": "rate_limit_exceeded",
"message": "Rate limit of 60 req/min exceeded. Retry after 15 seconds."
}Response headers
These headers are included on all successful and rate-limited responses:
| Header | Description |
|---|---|
X-RateLimit-Limit | Total requests allowed per window |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds to wait before retrying (only on 429) |
Quota exhaustion
When the monthly plan quota is exceeded:
HTTP/1.1 429 Too Many Requests
{
"error": "quota_exceeded",
"message": "Monthly search-unit quota exhausted. Upgrade your plan or wait for reset."
}Search units reset on the 1st of each calendar month. Upgrade the plan in Settings → Billing to immediately restore quota.
Retry strategy for clients
| Error class | Action |
|---|---|
| 4xx (except 429) | Do not retry — fix the request |
429 rate_limit_exceeded | Wait for Retry-After seconds, then retry |
429 quota_exceeded | Do not retry until quota resets or plan upgraded |
502 search_failed | Retry once after 1 second; if fails again, show error to user |
| 503 | Retry with exponential backoff: 1s → 2s → 4s → give up |
| Network error | Retry with exponential backoff |
SDK retry behavior
The @repo/search-client SDK does not automatically retry on 5xx errors. Implement retry
logic in your application layer or use a library like p-retry.
import pRetry from "p-retry";
const results = await pRetry(() => client.search({ q: "headphones" }), {
retries: 3,
factor: 2,
minTimeout: 1000,
shouldRetry: (err) => err.status >= 500, // only retry server errors
});CMS module retry behavior
The AACsearch ingest buffer is designed for retry:
- Connector sync requests (
sync/full,sync/delta) enqueue documents intoSearchIngestBuffer - The background worker retries failed rows with exponential backoff
- The CMS module does not need to implement retry for individual documents — the buffer handles it
- The CMS module should retry the HTTP request itself on 5xx responses (not on 4xx)
Development debugging
For 502 errors during development:
- Check if AACSearch is running:
curl http://localhost:8108/health - Check the API logs for the original search engine error (mapped before responding to client)
- Check
AACSEARCH_HOST,AACSEARCH_PORT,AACSEARCH_API_KEYin.env.local
For 401 errors:
- Verify the key has not expired
- Verify the key prefix matches the operation (
ss_search_*for search,ss_connector_*for ingest) - For scoped tokens: check the
expiresAtfield in the decoded payload
Request ID and trace ID
Every response includes two correlation identifiers — pass them to support so we can find the request in logs without back-and-forth.
| Header | Body field | Where it comes from |
|---|---|---|
X-Request-Id | requestId | Generated per-request by the API edge. Always present. |
X-Trace-Id | traceId (only present when distributed tracing is enabled) | OpenTelemetry trace ID, propagated through worker → AACSearch → response |
Capture them client-side from the SDK:
import { SearchClient } from "@aacsearch/client";
try {
const result = await client.search({ q: "shoes" });
} catch (err) {
if (err instanceof AacSearchError) {
console.error("aacsearch error", {
code: err.code,
requestId: err.requestId, // for support tickets
traceId: err.traceId, // for distributed tracing dashboards
});
}
}Server-side handlers should log them on every request, success or failure:
const result = await client.search(opts);
logger.info({
event: "search.ok",
requestId: result.requestId,
searchTimeMs: result.searchTimeMs,
});This way, when a customer reports "search felt slow at 14:32", you grep your logs by requestId and find the AACsearch request that matches — and we can join on that ID from our side.
SDK error class
The TypeScript SDK exposes a typed AacSearchError that wraps every non-2xx response:
import { AacSearchError } from "@aacsearch/client";
try {
await client.search({ q: "shoes" });
} catch (err) {
if (err instanceof AacSearchError) {
err.code; // string — error code from the body, stable
err.message; // string — human-readable
err.status; // number — HTTP status (0 for network errors)
err.retryable; // boolean — derived from body, fall back to status >= 500
err.requestId; // string | undefined
err.traceId; // string | undefined
err.docsUrl; // string | undefined
err.response; // Response | undefined — raw fetch Response if available
}
}Same shape in Python (SdkError) and PHP (AacSearchException).
UI state recipes
Concrete React components for the four most-common error states. All are headless — drop them into your design system.
Invalid key
function SearchInvalidKey({ requestId }: { requestId?: string }) {
return (
<div role="alert" className="search-error">
<h3>Search is not configured correctly</h3>
<p>Your API key is invalid, expired, or has been revoked.</p>
<p>
If you are the site owner, generate a new key in the AACsearch dashboard. Otherwise, please
try again later.
</p>
{requestId && <small>Reference: {requestId}</small>}
</div>
);
}Quota exceeded
function SearchQuotaExceeded({ resetDate }: { resetDate: Date }) {
return (
<div role="alert" className="search-error">
<h3>Search is temporarily unavailable</h3>
<p>The site has reached its monthly search limit.</p>
<p>Service will resume on {resetDate.toLocaleDateString()}.</p>
<p>
<a href="https://app.aacsearch.com/billing">Upgrade plan</a>
</p>
</div>
);
}No results
function NoResults({ query }: { query: string }) {
return (
<div className="no-results">
<h3>No results for "{query}"</h3>
<p>Try a shorter query, different keywords, or browse our categories below.</p>
<a href="/c/all">Browse all products →</a>
</div>
);
}For a richer no-results UI with did-you-mean, popular categories, and bestseller fallback, see the no-results recipe.
Server error / network
function SearchUnavailable({
requestId,
onRetry,
}: {
requestId?: string;
onRetry: () => void;
}) {
return (
<div role="alert" className="search-error">
<h3>Search is temporarily unavailable</h3>
<p>We could not reach the search service. Please try again in a moment.</p>
<button onClick={onRetry}>Retry</button>
{requestId && <small>Reference: {requestId}</small>}
</div>
);
}Wiring them in
Use the SDK error code to dispatch:
function SearchResults({ query }: { query: string }) {
const { data, error, refetch } = useQuery({
queryKey: ["search", query],
queryFn: () => client.search({ q: query }),
});
if (error instanceof AacSearchError) {
switch (error.code) {
case "unauthorized":
case "invalid_or_revoked_key":
return <SearchInvalidKey requestId={error.requestId} />;
case "quota_exceeded":
return <SearchQuotaExceeded resetDate={nextMonthFirst()} />;
case "rate_limit":
case "search_failed":
case "service_unavailable":
return <SearchUnavailable requestId={error.requestId} onRetry={refetch} />;
default:
return <SearchUnavailable requestId={error.requestId} onRetry={refetch} />;
}
}
if (data?.found === 0) return <NoResults query={query} />;
return <ResultGrid hits={data?.hits ?? []} />;
}For the full troubleshooting flow per error code, see the troubleshooting hub.
Related
- Troubleshooting hub — symptom → fix decision trees
- Auth errors — 401/403 deep dive
- Rate limits & quota — 429 deep dive
- Server-side helpers — production retry patterns
- No-results recommendations — richer empty-state UX
Search Core Relevance
Query processing, queryBy weights, typo tolerance, synonyms, curations, ranking — the developer-side reference for how the search engine decides which documents to return.
Reindexing & Zero-Downtime
How the alias-swap reindex strategy works, when to trigger it, and how to monitor progress.