The Brand Object

Slow, empty and failed responses

Handle the responses that are not a 200: the Brandfetch 503 while it builds a profile, the Logo.dev 202 while it indexes, cache misses and rate limits.

A brand context call can be slow the first time that someone asks for a domain. The call can be empty if the vendor cannot read the site. The vendor can also refuse the call because of a rate limit or a credit limit. Each vendor signals these cases in a different way. If the vendor tells you to wait, wait. If it has nothing to return, use your fallback. If you have no credits left, stop.

Endpoint Still working Nothing to return Slow down Out of credits
Brandfetch Brand Context API 503 with x-bf-error: generation_in_progress and Retry-After 404, 422 if the site blocks bots, or 204 with cachedOnly 429 (quota) 429
Brandfetch Brand API 404 crawl_queued 404, or 204 with cachedOnly Not listed 429
Context.dev The request stays open. timeoutOpts can return partial results 400 NOT_FOUND 429 with Retry-After 402 USAGE_EXCEEDED
Firecrawl 408 if the page is too slow A page's own 404 comes back as a document 429 with Retry-After 402
Logo.dev 202 with Retry-After: 30. Stop after 5 minutes 404 not_found 429. Wait for RateLimit-Reset 402
Rainbrand Brief status: pending or processing 404 BRAND_NOT_FOUND, or 409 if the brand is not ready 429 with Retry-After 402 INSUFFICIENT_CREDITS

One retry loop for all of them

const WAIT = new Set([202, 429, 503]);

export async function fetchWithWaits(url: string, init: RequestInit, giveUpAfterMs = 300_000) {
  const started = Date.now();
  for (;;) {
    const res = await fetch(url, init);
    if (!WAIT.has(res.status)) return res;
    // A 503 from Brandfetch's payment service also carries Retry-After.
    const seconds = Number(res.headers.get("retry-after") ?? res.headers.get("ratelimit-reset") ?? 30);
    if (Date.now() - started + seconds * 1000 > giveUpAfterMs) return res;
    await new Promise((r) => setTimeout(r, seconds * 1000));
  }
}

export function isEmpty(res: Response): boolean {
  return [204, 404, 422].includes(res.status);
}
import time
import httpx

WAIT = {202, 429, 503}

def fetch_with_waits(method: str, url: str, give_up_after: float = 300, **kwargs) -> httpx.Response:
    started = time.monotonic()
    while True:
        res = httpx.request(method, url, timeout=60, **kwargs)
        if res.status_code not in WAIT:
            return res
        seconds = float(res.headers.get("retry-after") or res.headers.get("ratelimit-reset") or 30)
        if time.monotonic() - started + seconds > give_up_after:
            return res
        time.sleep(seconds)

Context.dev answers "not found" with a 400 and the code NOT_FOUND. Before you treat a 400 as a bug, read the code in the body.

Waiting for a Rainbrand Brief

Rainbrand prepares a Brief after you request it. Get a Brief with wait=true holds the connection until the Brief is done or the timeout passes (up to 295 seconds). For this reason, the loop is short:

async function waitForBrief(id: string) {
  for (;;) {
    const res = await fetch(`https://www.rainbrand.com/api/v1/briefs/${id}?wait=true&timeout=120`, {
      headers: { "x-api-key": process.env.RAINBRAND_API_KEY! },
    });
    const { data } = await res.json();
    if (data.status === "completed") return data;
    if (data.status === "failed") throw new Error(`${data.error.code}: ${data.error.message}`);
  }
}

Our Brief took 42 seconds. In a failed Brief, data.error.retryable tells you if you can request the Brief again.

What to do when there is nothing

Do not send the model an empty brand. Tell the model what you do not have, for example: "No brand context was available for this company. Write in a neutral, plain voice. Do not describe its products." Then show the person what was missing, so that they can add it.

Last updated 12 November 2026