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
Code
HTTP/1.1 429 Too Many RequestsRetry-After: 1209600Content-Type: application/json
Code
{ "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.
Code
import osimport randomimport reimport timefrom datetime import datefrom openai import OpenAI, APIStatusErrorBASE_URL = "https://api.hyphen-solution.com/v1"PRIMARY = os.environ["HYPHEN_API_KEY"] # subscription key, monthly capCREDIT = os.environ.get("HYPHEN_CREDIT_KEY") # prepaid credit key, optionaldef 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 Nonedef 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)
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
Code
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.