DEV Community

Devil Scrapes
Devil Scrapes

Posted on

A 404 is not a failure: what Discord's invite endpoint taught us about billing

Quick answer: Discord's invite-lookup endpoint returns 404 for an expired, revoked or never-existed invite — and that is a normal answer, not a failure. Treat it as an exception and one dead link in a list of 500 takes down the whole run. Treat it as a result and you get a row that honestly says not_found. The same distinction decides what you may bill for: a lookup that resolved nothing produced nothing, so it costs the customer nothing.

Why is a 404 not an error here?

GET https://discord.com/api/v10/invites/{code}?with_counts=true is keyless — no bot token, no OAuth, no authenticated Discord API call. Hand it a live invite code and you get real guild metadata back: name, description, icon, verification level, and (only because of with_counts=true) approximate_member_count and approximate_presence_count.

Hand it a dead one and you get a clean 404. Invite links die constantly — they expire by design, get revoked, or get max-uses'd out — so in any real list of invite codes, some fraction of them are supposed to 404. That is the endpoint working correctly.

The classifier that falls out of it has three branches, not two:

def _terminal_result(response):
    """Return a terminal (non-retryable) result for 200/404, else None."""
    if response.status_code == HTTP_OK:
        return InviteLookupResult(found=True, payload=response.json())
    if response.status_code == HTTP_NOT_FOUND:
        return InviteLookupResult(found=False, payload=None)
    return None
Enter fullscreen mode Exit fullscreen mode

200 → found. 404terminal, resolved immediately, never retried. Anything else → not terminal, fall through to the retry logic. Retrying a 404 is pure waste: the invite will not un-expire on attempt two.

Note the return type. found=False comes back as a value, in a discriminated result object — it is not an exception. An expired invite isn't an exceptional condition in a bulk invite-resolution tool; it's one of the two things that can normally happen.

Where does the 429 wait time actually come from?

Discord rate-limits this endpoint, and it tells you how long to wait in two places that do not always agree: the standard Retry-After HTTP header, and a retry_after field inside the JSON body. Discord's own rate-limit documentation treats the body field as the precise one — it arrives as a float with sub-second resolution.

So the precedence has to be explicit, not accidental:

def _retry_after_from_response(response):
    """Resolve the 429 wait time — JSON body's `retry_after` wins over the header."""
    try:
        body = response.json()
    except ValueError:
        body = None
    if isinstance(body, dict) and isinstance(body.get(RETRY_AFTER_BODY_FIELD), int | float):
        return float(body[RETRY_AFTER_BODY_FIELD])
    header_value = response.headers.get(RETRY_AFTER_HEADER)
    ...
Enter fullscreen mode Exit fullscreen mode

Body first, header as the fallback, and a computed exponential backoff (2s doubling, capped at 30s) only when neither is present. The try/except ValueError matters more than it looks: a 429 from an edge proxy rather than from Discord itself may not carry a JSON body at all, and a rate-limit handler that raises while parsing its own rate-limit response is a fun thing to debug at 2am.

Why validate the invite code before the network call?

Because a malformed input has a knowable answer, and the cheapest request is the one you don't send.

Invite codes arrive in every shape a human can paste: a bare python, https://discord.gg/python, discord.com/invite/python, with www., with a trailing slash, with a ?utm_source= tail. All of those are the same code. And some inputs are simply not codes at all.

So normalisation is a pure function with no I/O — regex-extract the code from any of the accepted URL forms, then character-validate it against ^[A-Za-z0-9-]+$:

def normalize_invite(raw: str) -> NormalizedInvite:
    trimmed = raw.strip()
    extracted = _extract_from_url(trimmed)
    code = extracted if extracted is not None else trimmed
    return _validate_code(code)
Enter fullscreen mode Exit fullscreen mode

It never raises. An unparseable value comes back as is_valid=False with a human-readable reason, before any socket is opened. Two consequences: garbage input can't be mistaken for a network problem, and — because our pricing charges per resolved row — a malformed entry is never billed. The customer pays for servers we actually resolved, not for their own typos.

What keeps one bad invite from killing 499 good ones?

The transport layer and the loop have deliberately opposite jobs.

resolve_invite() raises DiscordTransportError after it exhausts 5 attempts. It does not swallow the failure — a module that hides transport errors makes an outage look like an empty dataset, and an empty dataset that reports SUCCEEDED is the single most expensive bug shape we ship, because it scores 100% on every health dashboard while delivering nothing.

Per-item fault isolation belongs one level up, in main.py's loop over invite codes. One code that 404s, one that times out after five attempts, one that's a malformed string — each becomes its own row with its own lookup_status (resolved / not_found / transport_error), and the other 499 still ship. The recurring cause of a low-success scraper isn't a hard target; it's a recoverable error taking down the whole run.

What a row looks like

{
  "input_value": "https://discord.gg/python",
  "invite_code": "python",
  "lookup_status": "resolved",
  "guild_name": "Python",
  "guild_description": "We're a large community focused around the Python programming language. We believe that anyone can learn to code.",
  "verification_level": 2,
  "approximate_member_count": 431375,
  "approximate_presence_count": 31004
}
Enter fullscreen mode Exit fullscreen mode

😈 Discord Server Metadata Lookup bulk-resolves Discord server metadata — name, description, icon, verification level, member and presence counts — from up to 500 invite codes or URLs per run, via Discord's own public invite-lookup endpoint. Metadata only: no message history, no member lists, no authenticated access. We validate every code locally for free, honour Discord's retry_after contract on 429, retry 408/5xx with exponential backoff, and isolate every failure to its own row. $5.20 per 1,000 results, and invalid, not-found and failed lookups are free.

FAQ

Does this need a Discord bot token?
No. GET /api/v10/invites/{code} is a public, keyless endpoint. No bot token, no OAuth, no authenticated Discord API call.

Why does the URL need ?with_counts=true?
Without it, the response omits approximate_member_count and approximate_presence_count entirely. The member-count fields most people want this data for are opt-in via that query parameter.

Should a 404 from the invite endpoint be retried?
No. It's a terminal, correct answer — the invite expired, was revoked, or never existed. Invite links die by design, so 404s are an expected fraction of any real input list, and retrying one just spends a request to be told the same thing.

Which wins on a 429 — the Retry-After header or the body's retry_after?
The JSON body's retry_after, because Discord ships it as a float with sub-second precision. The header is the fallback, and a computed exponential backoff (capped at 30s) is the fallback to that.

Can it read messages or member lists?
No, and that's a scope decision, not a limitation to work around. This resolves public invite metadata only.

Top comments (0)