AACsearch
SDKsCookbook

Fallback search

When AACsearch is unreachable (network blip, ongoing incident), degrade gracefully to a static catalog snapshot or a database query — never to a blank page.

A search box that returns "Network error, try again later" is worse than a search box that returns the right answer slightly slower. This recipe wires AACsearch as the primary path and a fallback as the safety net, transparent to the user.

What you build

A searchWithFallback() helper that:

  • Calls AACsearch first.
  • On network error or 5xx, falls back to a local catalog snapshot or a Postgres query.
  • Logs the fallback so you can monitor degradation.
  • Never throws to the UI — always returns a result shape.

Helper

import { SearchClient, AacSearchError } from "@aacsearch/client";
import { fallbackQuery } from "@/lib/fallback";

const client = new SearchClient({
	baseUrl: process.env.AACSEARCH_BASE_URL!,
	apiKey: process.env.AACSEARCH_SEARCH_KEY!,
	indexSlug: "products",
});

type SearchOptions = Parameters<typeof client.search>[0];
type SearchResult = Awaited<ReturnType<typeof client.search>>;

export async function searchWithFallback(opts: SearchOptions): Promise<SearchResult & { fallback?: true }> {
	try {
		const result = await client.search(opts);
		return result;
	} catch (err) {
		const isFallbackable =
			err instanceof AacSearchError &&
			(err.code === "search_failed" ||
				err.code === "service_unavailable" ||
				err.code === "network_error");

		if (!isFallbackable) {
			// Auth / rate-limit / quota — these are actionable bugs, not transient
			throw err;
		}

		console.warn("[search] AACsearch unavailable, using fallback:", err.code);
		// Send a metric so you can see how often this fires
		metric.increment("search.fallback", { tags: { reason: err.code } });

		const fallbackHits = await fallbackQuery(opts);
		return {
			hits: fallbackHits,
			found: fallbackHits.length,
			page: opts.page ?? 1,
			perPage: opts.perPage ?? 10,
			facetCounts: [],
			searchTimeMs: 0,
			fallback: true,
		};
	}
}

Two fallback strategies

A nightly cron exports the searchable catalog to a static JSON file in your CDN:

// scripts/snapshot-catalog.ts (run via cron at 02:00 UTC)
import { AdminClient } from "@aacsearch/client";
import { writeFile } from "node:fs/promises";

const admin = new AdminClient({
	baseUrl: process.env.AACSEARCH_BASE_URL!,
	apiKey: process.env.AACSEARCH_ADMIN_KEY!,
	projectId: process.env.AACSEARCH_ORG_ID!,
});

const all = await admin.exportDocuments("products"); // streams all docs
await writeFile(
	"public/catalog-snapshot.json",
	JSON.stringify(
		all.map((d) => ({
			id: d.id,
			title: d.title,
			brand: d.brand,
			price: d.price,
			categories: d.categories,
		})),
	),
);

Then fallbackQuery does a substring scan:

let cachedCatalog: Product[] | null = null;

async function loadCatalog() {
	if (cachedCatalog) return cachedCatalog;
	const res = await fetch("/catalog-snapshot.json");
	cachedCatalog = await res.json();
	setTimeout(() => (cachedCatalog = null), 30 * 60 * 1000); // 30-min cache
	return cachedCatalog;
}

export async function fallbackQuery(opts: SearchOptions) {
	const catalog = await loadCatalog();
	const q = opts.q?.toLowerCase() ?? "";
	const matches = catalog.filter(
		(p) => p.title.toLowerCase().includes(q) || p.brand.toLowerCase().includes(q),
	);
	const start = ((opts.page ?? 1) - 1) * (opts.perPage ?? 24);
	return matches.slice(start, start + (opts.perPage ?? 24)).map((document) => ({ document }));
}

Substring matching is much worse than AACsearch ranking, but it returns something relevant. Acceptable for a 5-minute outage.

Strategy B: database query (for self-hosted or B2B)

If you control the source data, query Postgres directly:

import { sql } from "@vercel/postgres";

export async function fallbackQuery(opts: SearchOptions) {
	const q = opts.q ?? "";
	const { rows } = await sql`
    SELECT id, title, brand, price, categories
    FROM products
    WHERE title ILIKE ${`%${q}%`}
       OR brand ILIKE ${`%${q}%`}
    ORDER BY popularity DESC
    LIMIT ${opts.perPage ?? 24}
    OFFSET ${((opts.page ?? 1) - 1) * (opts.perPage ?? 24)}
  `;
	return rows.map((document) => ({ document }));
}

This is more accurate than the snapshot strategy because the data is live, but it puts load on your primary database — only viable if your DB has spare capacity.

UI: surface the degradation

Tell the user when they are seeing fallback results, so they understand why filters or facets may be missing:

{result.fallback && (
	<div className="degraded-banner">
		Search is currently using a backup index. Some filters and sorting are unavailable.
		<a href="https://status.aacsearch.com">Check status</a>
	</div>
)}

Monitoring

The metric.increment("search.fallback") line above is critical. Without it, you may run for weeks on the fallback path without knowing. Wire it to your monitoring:

// Datadog / Cloudwatch / Honeycomb
metric.increment("search.fallback", { tags: { reason: err.code } });

// Or a structured log if you do not have metrics
logger.warn({ event: "search.fallback", reason: err.code });

Add an alert: "page on-call if search.fallback > 100/hour".

What NOT to fall back

  • 401 / 403 — your auth is broken. Falling back hides the problem.
  • 429 — you have a real load spike or quota issue. The fix is to scale, not to mask.
  • 400 — the request itself is malformed. Falling back returns wrong-looking results.

These should propagate to the UI so the engineer on-call sees them.

A fallback is a safety net, not a substitute. If you find yourself relying on it for routine traffic, that is a signal to scale AACsearch (raise rate limits, upgrade plan, contact support) — not to invest more in the fallback path.

On this page