AACsearch
Search API

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"
}
FieldStable?Use it for
erroryes — machine-readable, stable across versionsswitch/case in your error handler
messageno — human-readable, may change between versionsdisplay to engineers in dashboards/logs
retryableyes — booleanwire into retry logic (do not parse the code yourself)
requestIdyes — opaque ULIDpass to support to find the request in logs
docsUrlyes — present for documented errorslink 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)

HTTPerror codeCause
400invalid_requestMalformed JSON or missing required field
400invalid_filterInvalid filterBy expression syntax
400invalid_sortInvalid sortBy expression
401unauthorizedMissing, invalid, or expired API key or scoped token
401token_expiredScoped token TTL has passed
403forbiddenKey does not have required scope (search or connector_write)
403origin_not_allowedRequest origin not in allowedOrigins for the key
404index_not_foundThe specified indexSlug does not exist for this organization
429rate_limit_exceededPer-key rate limit exceeded
429quota_exceededMonthly search-unit quota for the organization is exhausted

5xx — Server errors (may retry with backoff)

HTTPerror codeCause
502search_failedUpstream search engine error (cluster unreachable, query timeout)
502ingest_failedFailed to enqueue documents to the ingest buffer
503service_unavailableAACsearch 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:

HeaderDescription
X-RateLimit-LimitTotal requests allowed per window
X-RateLimit-RemainingRequests remaining in current window
X-RateLimit-ResetUnix timestamp when the window resets
Retry-AfterSeconds 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 classAction
4xx (except 429)Do not retry — fix the request
429 rate_limit_exceededWait for Retry-After seconds, then retry
429 quota_exceededDo not retry until quota resets or plan upgraded
502 search_failedRetry once after 1 second; if fails again, show error to user
503Retry with exponential backoff: 1s → 2s → 4s → give up
Network errorRetry 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 into SearchIngestBuffer
  • 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:

  1. Check if AACSearch is running: curl http://localhost:8108/health
  2. Check the API logs for the original search engine error (mapped before responding to client)
  3. Check AACSEARCH_HOST, AACSEARCH_PORT, AACSEARCH_API_KEY in .env.local

For 401 errors:

  1. Verify the key has not expired
  2. Verify the key prefix matches the operation (ss_search_* for search, ss_connector_* for ingest)
  3. For scoped tokens: check the expiresAt field 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.

HeaderBody fieldWhere it comes from
X-Request-IdrequestIdGenerated per-request by the API edge. Always present.
X-Trace-IdtraceId (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 &quot;{query}&quot;</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.

On this page