Rate limits
Two layers sit between your request and an answer: the edge, by address, and the gateway, by key.
The edge (the TLS terminator in front of everything) limits by IP address and exists to make a credential-guessing flood or a reconnect storm cheap to refuse before it reaches anything that costs GPU time. The gateway then limits by key, and that is the budget that actually belongs to your account. You can hit either one; both answer 429 with the same envelope.
The edge, per IP address
| Path | Limit | Body cap |
|---|---|---|
/v1/* — no credential presented | 12 / min | 32 MB |
/v1/* — a credential presented (valid or not) | 600 / min | 32 MB |
/v1/audio/* | 60 / min | 200 MB |
/v1/files* | 60 / min | 256 MB |
/v1/models, /v1/pdf*, /v1/skills*, /v1/mcp*, /v1/vector_stores*, /v1/sandbox/*, /v1/route | 120 / min | 64 MB |
/v1/realtime* (connection attempts) | 60 / min | — |
"A credential presented" means the request carried a non-empty Authorization, x-api-key or api-key header — whether the key in it turns out to be valid is the gateway's question, not the edge's. The edge only separates "tried to authenticate" from "did not".
The gateway, per API key
| Tier | Requests / min | Tokens / min | Monthly spend cap | Free grant |
|---|---|---|---|---|
free | 20 | 60,000 | $5.00 | $5.00 |
paid | 300 | 1,000,000 | None | $0.00 |
rpm/tpm are stamped onto a key the moment it is minted. Moving your organisation to a higher tier changes the limit for keys you create *after* the change, not the ones already in your pocket — mint a new key (or ask whoever manages your org to) to pick up a new tier's throughput. The monthly spend cap and the free grant, by contrast, are read live and apply immediately.What comes back on every response
| Header | When |
|---|---|
x-ratelimit-limit-requests
x-ratelimit-remaining-requests | On every response, once your key carries an rpm limit. |
x-ratelimit-limit-tokens
x-ratelimit-remaining-tokens | On every response, once your key carries a tpm limit. |
retry-after | Only on 429 — seconds to wait. At the edge this counts down to a fixed one-minute window; at the gateway it counts down to the top of the next clock minute. |
The 429 itself
From the edge — an address over its budget, before any key was even checked:
{"error": {"message": "Too Many Requests", "type": "api_error", "code": "429"}}From the gateway — your key over its rpm or tpm budget for the current minute. The envelope matches every other gateway error (see Errors); the message names which dimension tripped and its current/limit values:
{
"error": {
"message": "LiteLLM Rate Limit Handler for rate limit type = key. Max parallel request limit reached. current rpm: 20, rpm limit: 20, current tpm: 4021, tpm limit: 60000, current max_parallel_requests: 1, max_parallel_requests: 100",
"type": "requests",
"param": null,
"code": "429"
}
}Backing off correctly
retry-after when it is present and wait at least that long. When it is not, back off exponentially from a small base with jitter, so that many clients throttled at the same instant do not all retry on the same beat and recreate the flood they backed off from. Cap the number of attempts and give up loudly rather than retrying forever.# Bash: exponential backoff with jitter, honouring Retry-After.
attempt=0
until [ $attempt -ge 6 ]; do
code=$(curl -s -o /tmp/resp.json -w "%{http_code}" \
https://api.console.larsa.larsima.com/v1/chat/completions \
-H "Authorization: Bearer $LARSA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"larsa-general\",\"messages\":[{\"role\":\"user\","\"content\":\"hi\"}]}")
[ "$code" -lt 429 ] && cat /tmp/resp.json && break
wait=$(( (2 ** attempt) + (RANDOM % 1000) / 1000 ))
sleep "$wait"
attempt=$((attempt + 1))
doneimport os
import random
import time
import requests
def call_with_backoff(payload, max_attempts=6):
for attempt in range(max_attempts):
resp = requests.post(
"https://api.console.larsa.larsima.com/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['LARSA_API_KEY']}"},
json=payload,
)
if resp.status_code != 429 and resp.status_code < 500:
resp.raise_for_status()
return resp.json()
wait = float(resp.headers.get("retry-after",
2 ** attempt + random.random()))
time.sleep(wait)
raise RuntimeError("gave up after repeated 429/5xx")async function callWithBackoff(payload, maxAttempts = 6) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch("https://api.console.larsa.larsima.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LARSA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (res.status !== 429 && res.status < 500) {
if (!res.ok) throw new Error(await res.text());
return res.json();
}
const retryAfter = res.headers.get("retry-after");
const wait = retryAfter ? Number(retryAfter) * 1000
: 2 ** attempt * 1000 + Math.random() * 1000;
await new Promise((r) => setTimeout(r, wait));
}
throw new Error("gave up after repeated 429/5xx");
}async function callWithBackoff(
payload: Record<string, unknown>,
maxAttempts = 6,
): Promise<unknown> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch("https://api.console.larsa.larsima.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LARSA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (res.status !== 429 && res.status < 500) {
if (!res.ok) throw new Error(await res.text());
return res.json();
}
const retryAfter = res.headers.get("retry-after");
const wait = retryAfter ? Number(retryAfter) * 1000
: 2 ** attempt * 1000 + Math.random() * 1000;
await new Promise((r) => setTimeout(r, wait));
}
throw new Error("gave up after repeated 429/5xx");
}package main
import (
"bytes"
"math"
"math/rand"
"net/http"
"os"
"strconv"
"time"
)
func callWithBackoff(payload []byte, maxAttempts int) (*http.Response, error) {
for attempt := 0; attempt < maxAttempts; attempt++ {
req, _ := http.NewRequest("POST",
"https://api.console.larsa.larsima.com/v1/chat/completions", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("LARSA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 429 && resp.StatusCode < 500 {
return resp, nil
}
wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
if ra := resp.Header.Get("Retry-After"); ra != "" {
secs, _ := strconv.Atoi(ra)
wait = time.Duration(secs) * time.Second
}
time.Sleep(wait + time.Duration(rand.Intn(1000))*time.Millisecond)
}
return nil, errors.New("gave up after repeated 429/5xx")
}use std::time::Duration;
use rand::Rng;
fn call_with_backoff(payload: &serde_json::Value, max_attempts: u32)
-> Result<serde_json::Value, Box<dyn std::error::Error>> {
let key = std::env::var("LARSA_API_KEY")?;
let client = reqwest::blocking::Client::new();
for attempt in 0..max_attempts {
let resp = client
.post("https://api.console.larsa.larsima.com/v1/chat/completions")
.bearer_auth(&key)
.json(payload)
.send()?;
let status = resp.status().as_u16();
if status != 429 && status < 500 {
return Ok(resp.error_for_status()?.json()?);
}
let wait = resp.headers().get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_millis(
2u64.pow(attempt) * 1000 + rand::thread_rng().gen_range(0..1000)));
std::thread::sleep(wait);
}
Err("gave up after repeated 429/5xx".into())
}import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
public class Backoff {
static HttpResponse<String> callWithBackoff(String jsonPayload, int maxAttempts)
throws Exception {
String key = System.getenv("LARSA_API_KEY");
HttpClient client = HttpClient.newHttpClient();
for (int attempt = 0; attempt < maxAttempts; attempt++) {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.console.larsa.larsima.com/v1/chat/completions"))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 429 && resp.statusCode() < 500) return resp;
long waitMs = resp.headers().firstValue("retry-after")
.map(v -> Long.parseLong(v) * 1000)
.orElse((long) Math.pow(2, attempt) * 1000
+ ThreadLocalRandom.current().nextInt(1000));
Thread.sleep(waitMs);
}
throw new RuntimeException("gave up after repeated 429/5xx");
}
}using System.Net.Http.Headers;
using System.Text;
async Task<HttpResponseMessage> CallWithBackoffAsync(string jsonPayload, int maxAttempts = 6) {
var key = Environment.GetEnvironmentVariable("LARSA_API_KEY");
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
var rand = new Random();
for (var attempt = 0; attempt < maxAttempts; attempt++) {
var resp = await client.PostAsync(
"https://api.console.larsa.larsima.com/v1/chat/completions",
new StringContent(jsonPayload, Encoding.UTF8, "application/json"));
if ((int)resp.StatusCode != 429 && (int)resp.StatusCode < 500) return resp;
var wait = resp.Headers.RetryAfter?.Delta
?? TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 1000 + rand.Next(1000));
await Task.Delay(wait);
}
throw new Exception("gave up after repeated 429/5xx");
}<?php
function callWithBackoff(array $payload, int $maxAttempts = 6): array {
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
$ch = curl_init("https://api.console.larsa.larsima.com/v1/chat/completions");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("LARSA_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($status !== 429 && $status < 500) {
return json_decode(substr($raw, $headerSize), true);
}
preg_match('/retry-after:\s*(\d+)/i', substr($raw, 0, $headerSize), $m);
$wait = isset($m[1]) ? (int)$m[1] : (2 ** $attempt) + random_int(0, 1000) / 1000;
sleep((int)ceil($wait));
}
throw new RuntimeException("gave up after repeated 429/5xx");
}require "net/http"
require "json"
require "uri"
def call_with_backoff(payload, max_attempts: 6)
uri = URI("https://api.console.larsa.larsima.com/v1/chat/completions")
max_attempts.times do |attempt|
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
"Authorization" => "Bearer #{ENV['LARSA_API_KEY']}")
req.body = payload.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
return JSON.parse(res.body) if res.code.to_i != 429 && res.code.to_i < 500
wait = res["retry-after"] ? res["retry-after"].to_f : (2**attempt) + rand
sleep(wait)
end
raise "gave up after repeated 429/5xx"
end