Browse documentation
Python
A small client with the retry policy the API actually needs.
There is no unlob package to install. The API is HTTP and JSON, so a client is one file.
pip install httpx
The client
import os, random, time
import httpx
class Unlob:
"""A client for the unlob search API.
Small on purpose. The only parts worth writing are the retry policy — which
has one non-obvious rule — and surfacing `partial`, which is a 200 that means
the answer is incomplete.
"""
RETRYABLE = {408, 500, 502, 503, 504}
def __init__(self, api_key: str | None = None, *, timeout: float = 20.0):
self._c = httpx.Client(
base_url="https://api.unlob.com",
headers={"x-api-key": api_key or os.environ["UNLOB_API_KEY"]},
timeout=timeout,
)
def _get(self, path: str, params: dict, attempts: int = 4) -> dict:
for attempt in range(attempts):
r = self._c.get(path, params={k: v for k, v in params.items() if v is not None})
if r.status_code == 429:
after = r.headers.get("retry-after")
if after is None:
# THE non-obvious rule: a 429 without retry-after is a
# hard-capped monthly quota, not a rate limit. Retrying it is a
# loop that cannot succeed.
raise QuotaExhausted(r.text)
time.sleep(int(after))
continue
if r.status_code in self.RETRYABLE and attempt < attempts - 1:
# Full jitter: without it, every client that failed together
# retries together and re-creates the spike.
time.sleep(random.uniform(0, 2 ** attempt))
continue
r.raise_for_status()
return r.json()
raise RuntimeError("exhausted retries")
def search(self, q: str, **filters) -> dict:
"""Search. Filters are the query parameters, verbatim — see the reference.
IN-lists take a comma-separated string: tld="gov,edu".
"""
return self._get("/search", {"q": q, **filters})
def document(self, passage_id: str) -> dict:
return self._get(f"/doc/{passage_id}", {})
def corroborate(self, passage_id: str, limit: int = 10) -> dict:
return self._get("/corroborate", {"id": passage_id, "limit": limit})
def dossier(self, entity: str, limit: int = 10) -> dict:
return self._get("/dossier", {"entity": entity, "limit": limit})
def context(self, question: str, budget: int = 4000) -> dict:
return self._get("/assemble_context", {"q": question, "budget": budget})
def why_not(self, url: str) -> dict:
return self._get("/why_not", {"url": url})
class QuotaExhausted(RuntimeError):
"""The monthly quota is spent and this key hard-caps. Not retryable."""
Using it
unlob = Unlob()
body = unlob.search("how does tokio schedule tasks", vertical="code", limit=5)
if body.get("partial"):
# A 200 that means the answer is incomplete: part of the corpus was
# unreachable. Short here is a symptom, not a finding.
raise RuntimeError("incomplete results — retry")
for hit in body["results"]:
print(hit["title"], hit["url"])
print(hit["snippet"]) # the passage text — usually the answer
print(hit.get("independent_sources", 0), "independent sources\n")
Filters
Every query parameter, verbatim. IN-lists are comma-separated strings, because a URL query string cannot carry an array:
unlob.search(
"vitamin d dosage",
tld="gov,edu",
min_host_rank=0.6,
min_independent_sources=2,
collapse="story",
published_from=1704067200,
sort="centrality",
limit=10,
)
Async
Same shape with httpx.AsyncClient. Worth it if you are fanning out across entities:
import asyncio, httpx
async def dossiers(entities: list[str], key: str) -> list[dict]:
async with httpx.AsyncClient(
base_url="https://api.unlob.com",
headers={"x-api-key": key},
timeout=20,
) as c:
rs = await asyncio.gather(*(c.get("/dossier", params={"entity": e}) for e in entities))
return [r.json() for r in rs]
Mind the per-minute rate limit when you do — x-ratelimit-remaining is on every response.
Reading the capability catalog
Rather than hard-coding the filter list:
import httpx
caps = httpx.get("https://api.unlob.com/describe").json() # no key needed
print({name for name, _ in caps["filters"]})
print(caps["verticals"]) # what this deployment holds