Blog
No bulk endpoint: enriching a list is N calls, paced with bounded concurrency under the limit
There is no bulk endpoint, so enriching N records means N calls. Here is the bounded concurrency pattern that stays under the 10 rps limit and collects errors.
Triguna has two lookup endpoints and neither one takes a list. Enriching 5,000 people is 5,000 calls to GET /v1/people/profile, one per identifier. There is no batch parameter to discover, no CSV upload, and no bulk tier to negotiate. That reads like a gap until you write the client, at which point it becomes the simplest kind of code to get right: a loop, a pace gate, and a place to put the failures.
The cost is multiplication, not a quote
One answered lookup is one credit at a flat $0.005, so N records is N * 0.005 dollars with nothing to model. Five thousand profiles is $25.00. There is no volume curve to forecast and no minimum to clear, which is the whole point of the pricing page fitting on one screen.
The one line that trips people up: a 404 is an answered lookup and bills a credit, because searching an identifier that turns out to have no record costs the same retrieval as finding one. An auth error, a rate limit, and a 5xx are all free. So a run of 5,000 that includes 200 misses still costs 5,000 credits, not 4,800. Budget on the count of calls you make, not the count that succeed.
The only real ceiling is 10 requests per second
The published rate limit is 10 rps. That is the number to design against, and it is not the same number as your concurrency. If each request round-trips in 200ms, eight in-flight workers will happily fire 40 requests a second and walk straight into a 429. Concurrency bounds how many calls are open at once; it does not bound how fast you start them.
So pace the starts, not the workers. A single shared gate that releases one request every 100ms holds you at 10 rps no matter how many workers pull from it.
const BASE = 'https://api.triguna.ai/v1';
const RETRYABLE = new Set([429, 502, 503]);
// One shared gate paces every request start to the published 10 rps,
// regardless of how many workers are running behind it.
let nextStart = 0;
function pace(rps = 10) {
const spacing = 1000 / rps;
const now = Date.now();
const wait = Math.max(0, nextStart - now);
nextStart = Math.max(now, nextStart) + spacing;
return wait ? new Promise((r) => setTimeout(r, wait)) : Promise.resolve();
}
The lookup: retry the free codes, keep the rest
Wrap the single call so a transient failure retries and a permanent one is recorded rather than thrown. Only 429, 502, and 503 are worth retrying, and all three are free, so a backoff loop never runs up the bill. A 404, 400, 401, and 402 are terminal: retrying a 404 pays a credit every single time, so it goes straight into the results as a miss.
async function lookup(profileId) {
const url = `${BASE}/people/profile?profile_id=${encodeURIComponent(profileId)}`;
for (let attempt = 0; ; attempt++) {
await pace();
const res = await fetch(url, { headers: { ApiKey: process.env.TRIGUNA_API_KEY } });
if (res.ok) return { id: profileId, ok: true, data: await res.json() };
if (!RETRYABLE.has(res.status) || attempt === 5) {
return { id: profileId, ok: false, status: res.status, body: await res.text() };
}
const wait = Number(res.headers.get('Retry-After') ?? 2 ** attempt);
await new Promise((r) => setTimeout(r, wait * 1000));
}
}
When you do cross the line, the 429 tells you exactly how long to wait:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
Honor Retry-After and it stops. Which status is safe to retry and which one bills if you do is the full subject of which enrichment errors to retry, and what every response header is telling you lives in provenance lives in the headers.
The fan-out: a bounded pool over the gate
With the gate doing the rate control, the pool only has to bound how many requests are open at once so a slow response cannot let thousands pile up in memory. A fixed set of workers pulling from a cursor is the whole mechanism.
async function enrichAll(ids, workers = 8) {
const out = new Array(ids.length);
let cursor = 0;
async function run() {
while (cursor < ids.length) {
const i = cursor++;
out[i] = await lookup(ids[i]);
}
}
await Promise.all(Array.from({ length: workers }, run));
return out;
}
const results = await enrichAll(profileIds);
const hits = results.filter((r) => r.ok);
const misses = results.filter((r) => !r.ok);
misses is not an exception to swallow. It is a worklist: the 404s are identifiers to fix (a display name or a urn:li: prefixed value will 404 a lookup that looks correct), and any leftover 5xx after five retries are candidates for a later pass. Company enrichment is the identical shape against GET /v1/companies/details, so the same pool drives both the Profile API and the Company API.
The Python version mirrors it one for one, with the gate as a small shared object:
import asyncio, os, time, httpx
BASE = "https://api.triguna.ai/v1"
RETRYABLE = {429, 502, 503}
class Pace:
"""Space request starts to a steady rate, shared across tasks."""
def __init__(self, rps=10):
self.spacing = 1 / rps
self.next_start = 0.0
self.lock = asyncio.Lock()
async def wait(self):
async with self.lock:
now = time.monotonic()
wait = max(0.0, self.next_start - now)
self.next_start = max(now, self.next_start) + self.spacing
if wait:
await asyncio.sleep(wait)
async def lookup(client, pace, profile_id):
for attempt in range(6):
await pace.wait()
res = await client.get(
f"{BASE}/people/profile",
params={"profile_id": profile_id},
headers={"ApiKey": os.environ["TRIGUNA_API_KEY"]},
)
if res.status_code == 200:
return {"id": profile_id, "ok": True, "data": res.json()}
if res.status_code not in RETRYABLE or attempt == 5:
return {"id": profile_id, "ok": False, "status": res.status_code, "body": res.text}
await asyncio.sleep(float(res.headers.get("Retry-After", 2 ** attempt)))
async def enrich_all(ids):
pace = Pace(10)
async with httpx.AsyncClient(timeout=30) as client:
return await asyncio.gather(*(lookup(client, pace, i) for i in ids))
For a list in the millions, feed the ids in chunks rather than building one coroutine per record, but the pacing logic does not change.
Where this pattern belongs
This is the in-process primitive: a list in hand, run it now, collect results and errors. Two neighbors reuse it. If the calls hang off a user action, keep them off the request path so a slow lookup never blocks a signup, which is the shape in enrich on signup. If the list is a scheduled sweep, the same gate plus a rule for which records are stale enough to refresh is a nightly backfill, where the budgeting is worked through end to end. And if you are still tuning a throttle to chase 429s at all, rate limits that have nothing to do with your request rate argues that on a healthy account there is usually nothing there to tune.
Next
- Which enrichment errors to retry, and which ones bill a credit if you do.
- Provenance lives in the headers: reading
Retry-AfterandX-RateLimit-Remaining. - Nightly backfill: the same pacing on a schedule, with the budget worked out.
- Enrich on signup: keeping the call off the request path.
- Pricing: one credit per answered lookup, so N records is N times $0.005.
- Endpoint and parameter detail: docs.triguna.ai.