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
Strategy A: nightly catalog snapshot (recommended for ecommerce)
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.
Related
- Errors and rate limits — which error codes are retryable
- Slow search — when to fall back vs wait
- DR recovery runbook — for service-wide outages
No-results recommendations
When `found: 0`, do not show a blank page. Show curated bestsellers, did-you-mean suggestions, and a search-tip that recovers the user's flow.
Scoped B2B catalog
Per-customer price tiers, customer-specific SKU visibility, and contract-based catalogs — all enforced server-side via scoped search tokens.