# Handling the 429 cap

Hyphen returns HTTP 429 for two different reasons and they need opposite
responses.

| Kind          | Meaning                                | What to do                              |
| ------------- | -------------------------------------- | --------------------------------------- |
| **Budget**    | Monthly cap spent on this key.         | Stop retrying. Switch keys or wait.     |
| **Fair use**  | Too many requests or tokens per minute.| Back off a few seconds and retry.       |

Retrying a budget 429 is pointless. It will keep failing until the window
resets, and every attempt is wasted latency. Tell them apart before you retry.

## What a budget 429 looks like

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 1209600
Content-Type: application/json
```

```json
{
  "error": {
    "message": "Budget has been exceeded! Monthly budget resets on 2026-08-01.",
    "type": "budget_exceeded",
    "code": "429"
  }
}
```

`type` is `budget_exceeded` and the reset date is in the message. A fair-use
429 has neither, and its `Retry-After` is seconds, not weeks.

## The full handler

Detects the kind, reads the reset date, backs off on fair-use limits, and falls
through to a prepaid credit key when the subscription key is spent.

```python
import os
import random
import re
import time
from datetime import date

from openai import OpenAI, APIStatusError

BASE_URL = "https://api.hyphen-solution.com/v1"
PRIMARY = os.environ["HYPHEN_API_KEY"]           # subscription key, monthly cap
CREDIT = os.environ.get("HYPHEN_CREDIT_KEY")     # prepaid credit key, optional


def error_body(err: APIStatusError) -> dict:
    try:
        return err.response.json().get("error", {}) or {}
    except Exception:
        return {}


def is_budget_error(err: APIStatusError) -> bool:
    body = error_body(err)
    if body.get("type") == "budget_exceeded":
        return True
    # Belt and braces: match the message if the type field ever moves.
    return "budget has been exceeded" in str(body.get("message", "")).lower()


def reset_date(err: APIStatusError) -> date | None:
    match = re.search(r"(\d{4})-(\d{2})-(\d{2})", str(error_body(err).get("message", "")))
    return date(*(int(g) for g in match.groups())) if match else None


def backoff_seconds(err: APIStatusError, attempt: int) -> float:
    """Prefer the server's Retry-After. Fall back to exponential plus jitter."""
    header = err.response.headers.get("retry-after")
    if header and header.isdigit():
        return min(float(header), 60.0)
    return min(2**attempt, 30) + random.random()


def complete(messages, model="minimax-m3", max_attempts=5):
    keys = [("subscription", PRIMARY)] + ([("credit", CREDIT)] if CREDIT else [])

    for label, key in keys:
        client = OpenAI(base_url=BASE_URL, api_key=key)
        for attempt in range(max_attempts):
            try:
                return client.chat.completions.create(
                    model=model, messages=messages, max_tokens=2000
                )
            except APIStatusError as err:
                if err.status_code != 429:
                    raise
                if is_budget_error(err):
                    when = reset_date(err)
                    print(f"{label} key: budget spent, resets {when or 'unknown'}")
                    break  # retrying this key will not help. Next key.
                wait = backoff_seconds(err, attempt)
                print(f"{label} key: fair-use limit, retrying in {wait:.1f}s")
                time.sleep(wait)

    raise RuntimeError("every key is capped or rate limited")


print(complete([{"role": "user", "content": "Say hi."}]).choices[0].message.content)
```

Run it with both keys exported:

```bash
export HYPHEN_API_KEY="sk-...subscription..."
export HYPHEN_CREDIT_KEY="sk-...credit..."
```

If `HYPHEN_CREDIT_KEY` is unset the helper just raises when the subscription key
caps. That is the correct behaviour for a script you do not want spending money
you did not budget.

## Node version

```javascript
import OpenAI from "openai";

const BASE_URL = "https://api.hyphen-solution.com/v1";
const keys = [process.env.HYPHEN_API_KEY, process.env.HYPHEN_CREDIT_KEY].filter(Boolean);

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function isBudgetError(err) {
  const body = err?.error?.error ?? err?.error ?? {};
  return (
    body.type === "budget_exceeded" ||
    String(body.message ?? "").toLowerCase().includes("budget has been exceeded")
  );
}

function resetDate(err) {
  const body = err?.error?.error ?? err?.error ?? {};
  return String(body.message ?? "").match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? null;
}

export async function complete(messages, model = "minimax-m3", maxAttempts = 5) {
  for (const apiKey of keys) {
    const client = new OpenAI({ baseURL: BASE_URL, apiKey, maxRetries: 0 });
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      try {
        return await client.chat.completions.create({ model, messages, max_tokens: 2000 });
      } catch (err) {
        if (err.status !== 429) throw err;
        if (isBudgetError(err)) {
          console.warn(`budget spent, resets ${resetDate(err) ?? "unknown"}`);
          break;
        }
        const retryAfter = Number(err.headers?.get?.("retry-after"));
        const wait = Number.isFinite(retryAfter) && retryAfter > 0
          ? Math.min(retryAfter, 60) * 1000
          : Math.min(2 ** attempt, 30) * 1000 + Math.random() * 1000;
        console.warn(`rate limited, retrying in ${Math.round(wait)}ms`);
        await sleep(wait);
      }
    }
  }
  throw new Error("every key is capped or rate limited");
}
```

`maxRetries: 0` turns off the SDK's own retry logic. Leave it on and the SDK
will silently retry budget 429s for you, which wastes seconds and hides the
real error.

## Checking the cap before you start

Cheaper than finding out mid-run. Send one tiny request and look at the status.

```python
from openai import OpenAI, APIStatusError

def key_is_live(key: str) -> bool:
    client = OpenAI(base_url="https://api.hyphen-solution.com/v1", api_key=key, max_retries=0)
    try:
        client.chat.completions.create(
            model="minimax-m2.5",
            messages=[{"role": "user", "content": "hi"}],
            max_tokens=2000,
        )
        return True
    except APIStatusError as err:
        return err.status_code != 429
```

Use `minimax-m2.5` for the probe. Same rate as the flagship, and it is the
speed-and-volume tier.

## Things not to do

- **Do not retry a budget 429 in a tight loop.** It will not clear until the
  window resets.
- **Do not treat 429 as fatal without checking the type.** A fair-use 429 is
  routine and clears in seconds.
- **Do not shrink `max_tokens` to save budget.** It truncates the answer without
  reducing reasoning cost. Trim the input instead. See
  [Choosing a model](/choosing-a-model#the-max_tokens-gotcha).
- **Do not swallow the reset date.** Surface it. Your users need to know when
  the thing comes back.

## Related

- [Rate limits & caps](/rate-limits): how the budget works.
- [A small agent loop](/recipes/agent-loop): where this helper belongs.
- [Models](/models): the rate card, so you know what is spending your cap.
