Guides

Handle India API errors, rate limits and retries

Every error has the same JSON shape; rate limits are weighted per endpoint and told in RateLimit headers. A reusable client in Python and JavaScript with timeouts, retries and backoff.

Every endpoint answers errors the same way, so one small wrapper handles them all. This guide shows what can go wrong, what each status means, and a client that retries only what is worth retrying.

One error shape

A successful answer is { "data": ..., "meta": ... }. An error is always:

{"error": {"code": "not_found", "message": "No post office found for pincode 999999."}}

code is for your program, message for people (it says what to fix). That one is the answer to GET /v1/pincode/999999, a pincode that does not exist.

Status Codes What to do
400 bad_request Your input is wrong; show the message, do not retry
401 key_required, invalid_key Send a key (Authorization: Bearer <key> or X-API-Key); do not retry
404 not_found The thing does not exist (an unknown pincode or IFSC); do not retry
405 method_not_allowed Wrong method; fix the code
413 too_large The body is over the endpoint's limit; send less
429 rate_limited, quota_exceeded, token_quota_exceeded Wait for Retry-After seconds, then retry
503 busy, dataset_unavailable, not_available Retry after Retry-After; a model or dataset is loading or the server is busy
500 internal_error Retry once later; report it if it stays

Weighted rate limits

Without a key each address has 600 units a minute; a free key has 3,000. A lookup costs 1 unit, heavier work more (a file upload 15, a model 30). Every charged answer says where you stand:

RateLimit-Limit: 600
RateLimit-Remaining: 559
RateLimit-Reset: 50

RateLimit-Reset is the seconds until the minute's allowance refills. Model endpoints also send X-Quota-Remaining (requests left today) and the LLM endpoints X-Token-Remaining.

Cache what does not change

A pincode or an IFSC rarely changes, and answers carry Cache-Control and an ETag. Keep answers for a day in your own cache, and send If-None-Match with the ETag to get a free 304 Not Modified when nothing changed. A logo answered from cache does not count against the limit at all.

A client in Python

import random
import time
import requests

BASE = "https://api.dharun.dev/v1"
RETRY = {429, 500, 502, 503, 504}

class IndiaAPIError(Exception):
    def __init__(self, status, code, message):
        super().__init__(f"{status} {code}: {message}")
        self.status, self.code = status, code

def get(path, params=None, key=None, tries=4):
    headers = {"X-API-Key": key} if key else {}
    for attempt in range(tries):
        try:
            r = requests.get(f"{BASE}{path}", params=params, headers=headers, timeout=10)
        except requests.RequestException:
            if attempt == tries - 1:
                raise
            time.sleep(2 ** attempt + random.random())
            continue
        if r.ok:
            return r.json()["data"]
        if r.status_code in RETRY and attempt < tries - 1:
            wait = float(r.headers.get("Retry-After", 2 ** attempt))
            time.sleep(min(wait, 60) + random.random())
            continue
        error = r.json().get("error", {}) if r.headers.get("Content-Type", "").startswith("application/json") else {}
        raise IndiaAPIError(r.status_code, error.get("code", "http_error"), error.get("message", r.text[:200]))

try:
    print(get("/pincode/637001")[0]["district"])
except IndiaAPIError as e:
    if e.code == "not_found":
        print("no such pincode")
    else:
        raise

A client in JavaScript

const BASE = "https://api.dharun.dev/v1";
const RETRY = new Set([429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function get(path, { key, tries = 4, signal } = {}) {
  for (let attempt = 0; attempt < tries; attempt++) {
    let r;
    try {
      r = await fetch(BASE + path, {
        headers: key ? { "X-API-Key": key } : {},
        signal: signal ?? AbortSignal.timeout(10_000),
      });
    } catch (e) {
      if (e.name === "AbortError" && signal?.aborted) throw e; // the caller gave up
      if (attempt === tries - 1) throw e;
      await sleep(1000 * 2 ** attempt + Math.random() * 500);
      continue;
    }
    if (r.ok) return (await r.json()).data;
    if (RETRY.has(r.status) && attempt < tries - 1) {
      const wait = Number(r.headers.get("Retry-After")) || 2 ** attempt;
      await sleep(Math.min(wait, 60) * 1000 + Math.random() * 500);
      continue;
    }
    const body = await r.json().catch(() => ({}));
    const err = new Error(body.error?.message ?? r.statusText);
    err.status = r.status;
    err.code = body.error?.code;
    throw err;
  }
}

// const branch = await get("/ifsc/SBIN0000001");

Check an IFSC with it

curl "https://api.dharun.dev/v1/ifsc/SBIN0000001?utm_medium=guide"

A 404 here means the code is not in the Reserve Bank's list: tell the user to check it, do not retry. A 429 or 503 is the only kind worth a second try.

In a browser

CORS is open, and the RateLimit-*, Retry-After, X-Quota-* and X-Token-* headers are exposed to scripts, so the same code works in a web page. Keep keys out of public pages: the endpoints most pages call need no key.

The code in this guide is MIT-0: copy it into anything, no credit needed. Updated 2026-11-12.

APIs used: Pincodes, IFSC and banks.