NEWn1n v2.0.1 is live! Enterprise Unified LLM API Gateway with 500+ AI Models, up to 90% off,Try now

Building an OpenAI Compatible Gateway with Automatic Dual Upstream Failover

Authors
  • avatar
    Name
    Nino
    Occupation
    Senior Tech Editor

In production environments, depending on a single LLM provider is a recipe for disaster. During peak hours, API endpoints frequently experience latency spikes, rate limits, or outright outages. For instance, when DeepSeek's API experiences temporary fluctuations, batch jobs and real-time user chats can fail, leading to degraded user experiences. While we can modify our application logic to catch these errors and retry with alternative models, doing so pollutes the codebase with infrastructure concerns.

The most elegant solution is to decouple the failover logic from the application. By building a lightweight, OpenAI-compatible API gateway, you can route all LLM requests through a single local endpoint. If the primary provider fails, the gateway automatically switches to a backup provider. The application remains completely unaware of the swap, requiring zero changes to its business logic beyond updating the base_url in the OpenAI SDK.

While building a custom gateway is an excellent learning exercise, managing infrastructure, latency, and multi-region routing at scale can become a full-time job. For production workloads that require enterprise-grade reliability without the maintenance overhead, platforms like n1n.ai offer managed routing, unified billing, and instant failover across dozens of LLM providers.

The Architecture of a Resilient LLM Gateway

Our custom gateway is designed around a few core requirements:

  1. OpenAI Compatibility: It must expose a /v1/chat/completions endpoint that accepts standard OpenAI-formatted payloads.
  2. Automatic Failover: If the primary upstream provider fails (e.g., returns a 5xx status code or times out after 8 seconds), the gateway must transparently fall back to the secondary provider.
  3. Streaming Support: It must support Server-Sent Events (SSE) for real-time streaming responses without buffering or truncating chunks.
  4. Quota and Log Management: It should log usage metrics and enforce simple token quotas using a local SQLite database.

Here is a high-level overview of the request lifecycle through our gateway:

[Client Application] 
        (OpenAI SDK / v1/chat/completions)
[FastAPI Gateway] 
       ├─► [Primary Upstream: SiliconFlow] (Timeout < 8s?)
 (If Fail / Timeout)
       │         ▼
       └─► [Fallback Upstream: DeepSeek Official]
[SQLite Database] (Log Usage & Verify Token Quota)

Complete Implementation in 200 Lines of Python

Below is the complete implementation of the gateway using FastAPI, httpx for asynchronous HTTP requests, and aiosqlite for non-blocking database transactions.

import json
import time
import asyncio
import logging
from typing import AsyncGenerator
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import StreamingResponse
import httpx
import aiosqlite

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("llm-gateway")

app = FastAPI()

# Configuration Constants
DATABASE_PATH = "gateway.db"
PRIMARY_URL = "https://api.siliconflow.cn/v1/chat/completions"
PRIMARY_KEY = "sk-siliconflow-key-here"
FALLBACK_URL = "https://api.deepseek.com/v1/chat/completions"
FALLBACK_KEY = "sk-deepseek-key-here"
TIMEOUT_LIMIT = 8.0  # seconds

# Initialize SQLite database schema
async def init_db():
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS users (
                api_key TEXT PRIMARY KEY,
                token_quota INTEGER,
                token_used INTEGER DEFAULT 0
            )
        """)
        await db.execute("""
            CREATE TABLE IF NOT EXISTS usage_logs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                api_key TEXT,
                upstream TEXT,
                prompt_tokens INTEGER,
                completion_tokens INTEGER,
                timestamp REAL
            )
        """)
        # Seed a test user key if not exists
        await db.execute(
            "INSERT OR IGNORE INTO users (api_key, token_quota) VALUES (?, ?)",
            ("sk-local-test-key", 500000)
        )
        await db.commit()

@app.on_event("startup")
async def startup_event():
    await init_db()

# Helper to authenticate and check quotas
async def verify_user(request: Request) -> str:
    auth_header = request.headers.get("Authorization")
    if not auth_header or not auth_header.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Invalid or missing API key")
    api_key = auth_header.split(" ")[1]
    
    async with aiosqlite.connect(DATABASE_PATH) as db:
        async with db.execute(
            "SELECT token_quota, token_used FROM users WHERE api_key = ?", 
            (api_key,)
        ) as cursor:
            row = await cursor.fetchone()
            if not row:
                raise HTTPException(status_code=401, detail="Unauthorized key")
            quota, used = row
            if used >= quota:
                raise HTTPException(status_code=429, detail="Quota exceeded")
    return api_key

# Helper to log token consumption
async def log_usage(api_key: str, upstream: str, prompt: int, completion: int):
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute(
            """
            INSERT INTO usage_logs (api_key, upstream, prompt_tokens, completion_tokens, timestamp)
            VALUES (?, ?, ?, ?, ?)
            """,
            (api_key, upstream, prompt, completion, time.time())
        )
        await db.execute(
            "UPDATE users SET token_used = token_used + ? WHERE api_key = ?",
            (prompt + completion, api_key)
        )
        await db.commit()

async def forward_stream(response: httpx.Response, api_key: str, upstream_name: str) -> AsyncGenerator[str, None]:
    total_prompt = 0
    total_completion = 0
    try:
        async for line in response.aiter_lines():
            if not line.strip():
                continue
            yield f"{line}\n\n"
            
            # Attempt to parse chunk for token usage tracking
            if line.startswith("data: "):
                data_str = line[6:]
                if data_str.strip() == "[DONE]":
                    continue
                try:
                    data_json = json.loads(data_str)
                    # Extract usage metadata if provided by the upstream
                    usage = data_json.get("usage")
                    if usage:
                        total_prompt = usage.get("prompt_tokens", 0)
                        total_completion = usage.get("completion_tokens", 0)
                except json.JSONDecodeError:
                    pass
    finally:
        await response.aclose()
        # Log final usage metrics
        if total_prompt > 0 or total_completion > 0:
            await log_usage(api_key, upstream_name, total_prompt, total_completion)

async def attempt_request(client: httpx.AsyncClient, url: str, key: str, payload: dict) -> httpx.Response:
    headers = {
        "Authorization": f"Bearer {key}",
        "Content-Type": "application/json"
    }
    # Send request with a strict timeout limit
    response = await client.post(url, json=payload, headers=headers, timeout=TIMEOUT_LIMIT)
    if response.status_code >= 500:
        raise httpx.HTTPStatusError("Upstream server error", request=response.request, response=response)
    return response

@app.post("/v1/chat/completions")
async def chat_completions(request: Request, api_key: str = Depends(verify_user)):
    payload = await request.json()
    stream = payload.get("stream", False)
    
    upstreams = [
        ("SiliconFlow", PRIMARY_URL, PRIMARY_KEY),
        ("DeepSeek Official", FALLBACK_URL, FALLBACK_KEY)
    ]
    
    async with httpx.AsyncClient() as client:
        for name, url, key in upstreams:
            try:
                logger.info(f"Attempting request using upstream: {name}")
                response = await attempt_request(client, url, key, payload)
                
                if stream:
                    # Return custom streaming response iterator
                    return StreamingResponse(
                        forward_stream(response, api_key, name),
                        media_type="text/event-stream"
                    )
                else:
                    # Handle standard non-streaming response
                    data = response.json()
                    usage = data.get("usage", {})
                    prompt = usage.get("prompt_tokens", 0)
                    completion = usage.get("completion_tokens", 0)
                    await log_usage(api_key, name, prompt, completion)
                    return data
                    
            except (httpx.RequestError, httpx.HTTPStatusError, asyncio.TimeoutError) as e:
                logger.warning(f"Upstream {name} failed with error: {str(e)}. Attempting failover...")
                continue
                
        # If all upstreams are exhausted, raise 502
        raise HTTPException(status_code=502, detail="All upstream providers failed or timed out.")

Production Gotchas and Pitfalls

Implementing a basic proxy is simple, but achieving production-grade resilience requires navigating several edge cases.

1. Token Counting Normalization

Upstream LLM providers (e.g., SiliconFlow, DeepSeek, OpenAI) do not always return usage metrics under the same JSON field names, especially in streaming mode. Some providers return the usage block in the very last chunk (data: [DONE]), while others send it in a custom chunk immediately preceding the end marker. If your parser fails to capture this block, your database will record 0 tokens consumed.

Pro Tip: Always normalize the token consumption schema. If the upstream provider does not return usage metrics in streaming mode, you should fallback to a local tokenizer like tiktoken to estimate the prompt and completion lengths.

2. Streaming SSE Truncation Issues

When a failure occurs before headers are sent, our failover loop works perfectly. However, if an upstream provider starts streaming successfully and then drops the connection midway through the generation, the gateway cannot simply fall back to the next provider. Part of the response has already been sent to the client. Attempting to switch providers mid-stream would result in a corrupted response with duplicated prefixes.

For mid-stream failures, the gateway must gracefully close the connection and let the client application handle the partial response. Advanced gateways implement "smart buffering" for the first few tokens to ensure connection stability before committing the stream to the client.

3. Concurrency and Race Conditions

In our simple SQLite implementation, token quotas are tracked by subtracting usage from the database. When handling concurrent requests under load, float-based token balances can lead to race conditions and negative balances. To mitigate this:

  • Store quotas and usage exclusively as integers.
  • Use SQL transactions (BEGIN TRANSACTION) and row locks or atomic updates: UPDATE users SET token_used = token_used + ? WHERE api_key = ?
  • For high-concurrency environments, migrate from SQLite to Redis using DECRBY operations for atomic token bucket rate limiting.

Custom Gateway vs. Managed API Aggregators

Building your own proxy is highly customizable, but it introduces operational overhead. Let's compare hosting this gateway yourself against using a consolidated API provider like n1n.ai:

FeatureCustom FastAPI Gatewayn1n.ai Aggregator
Infrastructure OverheadHigh (Requires VPS, SSL, Docker, Monitoring)Zero (Serverless API)
Failover LatencyLimited by single-server geographic locationUltra-low (Global edge network)
Model CoverageManually configured upstreamsInstantly access DeepSeek-V3, Claude 3.5 Sonnet, OpenAI o3, etc.
Token AccountingCustom database logic (susceptible to race conditions)Enterprise-grade billing and analytics
Maintenance CostDeveloper hours spent on updates and bug fixesPay-as-you-go usage

Conclusion

Building a custom gateway with automatic failover ensures that your applications remain online even when primary API providers experience outages. By changing your SDK configuration to point to a local proxy, you shield your application logic from upstream instability.

However, if you want to avoid managing the infrastructure, monitoring latency, and writing complex token normalization logic, utilizing a highly-available infrastructure like n1n.ai is the most efficient path forward. It gives you immediate access to redundant paths for the world's leading LLMs through a single, unified API key.

Get a free API key at n1n.ai