Case Study: Search API
Product-wide search over documents, projects, and people: one query box, filters, sorting, and paginated results from a live, constantly-changing index.
Search looks like GET /search?q= and hides two contract problems that plain list endpoints never face. First, cost is caller-controlled: a filter, a fuzzy term, and a deep page multiply into queries that are 1000× more expensive than the median, so the contract needs complexity limits the way an upload API needs size limits (Large Requests and Documented Limits). Second, the result set is a moving target: documents are created, edited, and re-ranked while the user pages through, and the contract must say what pagination means over data that won't hold still (Search Is a Different Contract Than Filtering). The design below answers both the same way: promise less, explicitly — bounded depth, snapshot-consistent pages, best-effort counts — rather than implying guarantees the index cannot keep.
Consumers
Fast (<200ms p95) first page, facet counts for the filter sidebar, highlighted snippets, next-page on scroll.
Top 5 hits across types on every keystroke — extreme rate, tiny responses, relevance over completeness.
Programmatic search over the public API with the same permission trimming a user would get — and no way to run denial-of-service queries.
Requirements
- • Full-text query with typo tolerance across heterogeneous types (documents, projects, people), filterable by type, owner, date, tags.
- • Results are permission-trimmed: nobody ever sees a hit they couldn't open — not even its title.
- • Sort by relevance (default), recency, or name; pagination is stable enough that scrolling never shows the same hit twice.
- • Facet counts for the sidebar in the same round trip as results.
- • One caller's pathological query cannot degrade search for everyone else.
- • Freshness is honest: the docs state how quickly an edit becomes searchable (target: <10s), because "immediately" would be a lie.
Resources
A projection, not an entity: id, type, title, snippet with highlights, score, and a `url` to the real resource. Keeping results thin means the index never becomes a second copy of every API's response schema that must evolve in lockstep.
Not a stored resource in V1, but the request schema is versioned and validated like one: allowlisted filter fields, bounded term counts, typed values — because the query language *is* the attack and cost surface ([[filtering]]).
Aggregated counts per filter dimension, returned alongside results. Modeled explicitly (requested via `facets=owner,type`) because each facet costs an aggregation — callers ask for what they render, not everything.
Deferred to V2 deliberately, but named in V1 design: knowing it's coming is why the query schema is a serializable object rather than an ad-hoc parameter soup.
Operations
| Operation | Purpose | Design notes |
|---|---|---|
| GET /search | The main event: `q`, `type`, `filter.*`, `sort`, `cursor`, `limit`, `facets`. | GET, not POST: search is safe and cacheable, and shareable result URLs are a product feature. The documented URL-length ceiling (2KB) is also the first complexity limit in disguise (GET: The Promise of Safety). |
| GET /search/suggest | Prefix completions for the quick-search box. | A separate endpoint, not a ?mode= flag: 20× the rate, 1/20th the work, different caching (30s TTL is fine), different rate budget. One endpoint serving both profiles would need the union of their guarantees. |
| GET /search?cursor=… | Continue a result set. | The cursor encodes query hash, sort position, *and an index snapshot marker*: continuation reads the same index generation, so paging is duplicate-free even as documents churn. Expires in 5 minutes — a deliberate promise-narrowing that makes stability affordable (Cursor Pagination: An Opaque Bookmark, Not a Position). |
| GET /search/count | Exact count for a query, when someone truly needs it. | Split from /search because exact counting is often costlier than the first page. The main response carries total: {value, relation: "eq" | "gte"} — honest approximation (≥10,000) by default; the expensive precision is opt-in and rate-limited separately. |
| POST /search/queries/validate | Dry-run a query: is it legal, and what would it cost? | Returns the computed complexity score and limit without executing. Exists for integrators: the alternative is discovering QUERY_TOO_COMPLEX in production (Documentation Is Part of the Contract as a runtime service). |
| GET /search/fields | Machine-readable catalog of filterable/sortable fields per type. | The allowlist, published: integrators discover capabilities instead of probing, and deprecating a field starts by marking it here. |
Error contract
| Code | Status | When | Retryable |
|---|---|---|---|
| INVALID_QUERY | 400 | Unparseable syntax, unknown filter field, wrong value type. `details` names the offending part — search queries are user-typed, so error quality is UX. | no |
| QUERY_TOO_COMPLEX | 422 | Complexity score over budget: too many terms/clauses, wildcard-heavy patterns, too many facets. Body carries `score`, `limit`, and which components cost the most — actionable, not just "no". | no |
| PAGE_DEPTH_EXCEEDED | 422 | Paging past result 5,000. Deep paging costs grow with depth and no human reads page 250 — bulk consumers are pointed at the export flow instead ([[unbounded-collections]]). | no |
| CURSOR_EXPIRED | 410 | A cursor older than 5 minutes, or spanning an index rebuild. `410` (not `400`) tells the client this cursor *was* valid: re-run the query to get a fresh snapshot. | no |
| RATE_LIMITED | 429 | Per-caller budget exceeded — suggest traffic and full search are budgeted separately so cmd-K can never starve the search page. | after delay |
| INDEX_UNAVAILABLE | 503 | The search cluster is degraded. `Retry-After` set; the docs tell UI clients to degrade to recent-items rather than hard-fail the whole page. | after delay |
Decision log
Decision → reason → alternative → trade-off. The alternative is part of the record.
*a* across all types with 10 facets can be 1000× the median query. Post-hoc timeouts kill the query *after* it hurt the cluster; admission control rejects it before (Large Requests and Documented Limits).How it evolves
- • New searchable type (comments) is additive by construction: results carry
type, and V1 docs required clients to skip unknown types — new types appear only when a request opts in viatype=or the client declares support (Enum Evolution: The New Value That Broke Old Clients applied to result payloads). - • Semantic/vector search lands as
mode: "hybrid" | "lexical"(defaultlexical) plus arank_signalsdebug field — ranking changes ship behind an explicit switch first, because silently changing relevance is a behavioral break dashboards notice even if schemas don't. - • Saved searches and alerts promote the already-serializable query object into a stored
SavedSearchresource with CRUD and a notification hook — the V1 decision to keep queries structured pays off here. - • Bulk export for integrators arrives as an async job (
POST /search/exports→202, job id, S3-style result delivery) rather than lifting the 5,000-result depth cap — deep paging pressure is routed to the pattern built for it (The Async Job Pattern).