n4nAI

Correlating request IDs across LLM retries and fallbacks

Learn how to implement correlating request IDs across LLM retries and fallbacks with structured logging, OpenAI-compatible clients, and runnable Python code.

n4n Team4 min read808 words

Audio narration

Coming soon — every post will get a voice note here.

Correlating request IDs across LLM retries is the difference between a debuggable production incident and a blind guess. When your client retries a timed-out completion or a gateway silently falls back to a secondary provider, you need one identifier that survives every attempt. This guide shows how to build that correlation into your logging from the edge of your service down to the model response.

Step 1: Generate a stable correlation ID at the entrypoint

Do not wait until you call the model to create a trace ID. Generate it at the HTTP boundary so every downstream system can inherit it. If a client already sends X-Correlation-ID, reuse it; otherwise mint a uuid4.

import uuid
from fastapi import Request

@app.middleware("http")
async def correlation_middleware(request: Request, call_next):
    correlation_id = request.headers.get("X-Correlation-ID") or str(uuid.uuid4())
    request.state.correlation_id = correlation_id
    response = await call_next(request)
    response.headers["X-Correlation-ID"] = correlation_id
    return response

This middleware stamps every response with the same ID and stores it on request.state for handlers to pass along. Use a UUIDv4 unless you have a centralized ID generator; randomness avoids collision without coordination.

Step 2: Propagate the ID through your LLM client wrapper

When correlating request IDs across LLM retries, you must ensure the identifier is attached to every outbound call, not just the first. Wrap the OpenAI-compatible client so the header is injected on each attempt and the ID is threaded into logs.

import time, json, logging
import openai

logger = logging.getLogger("llm")

class RetryLLMClient:
    def __init__(self, base_url: str, api_key: str, correlation_id: str):
        self.client = openai.OpenAI(base_url=base_url, api_key=api_key)
        self.correlation_id = correlation_id

    def complete(self, model: str, messages: list, max_retries: int = 3):
        for attempt in range(max_retries):
            start = time.monotonic()
            try:
                resp = self.client.chat.completions.create(
                    model=model,
                    messages=messages,
                    extra_headers={"X-Correlation-ID": self.correlation_id},
                )
                logger.info(json.dumps({
                    "correlation_id": self.correlation_id,
                    "event": "llm_success",
                    "attempt": attempt,
                    "requested_model": model,
                    "served_model": resp.model,
                    "provider_request_id": resp.id,
                    "latency_ms": int((time.monotonic() - start) * 1000),
                }))
                return resp
            except openai.APIError as e:
                provider_id = None
                if hasattr(e, "response") and e.response is not None:
                    provider_id = e.response.headers.get("x-request-id")
                logger.warning(json.dumps({
                    "correlation_id": self.correlation_id,
                    "event": "llm_error",
                    "attempt": attempt,
                    "requested_model": model,
                    "provider_request_id": provider_id,
                    "error": str(e),
                    "latency_ms": int((time.monotonic() - start) * 1000),
                }))
                if attempt == max_retries - 1:
                    raise
                time.sleep(2 ** attempt)

The extra_headers parameter is honored by any OpenAI-compatible endpoint. If you later switch base URLs, the correlation header travels with the request.

Production code should replace the fixed time.sleep(2 ** attempt) with jittered backoff and a circuit breaker. The tenacity library handles both cleanly. Retries are not free: each attempt consumes tokens and may double-bill if you do not check idempotency. For streaming calls, never retry mid-stream; abort and start a fresh request with the same correlation ID so the log chain stays intact.

Capture provider-assigned IDs

On success, resp.id is the provider’s request identifier (e.g., chatcmpl-...). On failure, inspect e.response.headers for x-request-id or similar before retrying. Add that to the error log so you can cite it in a provider support ticket.

Step 3: Emit structured logs with attempt metadata

String-formatted log lines are useless when you need to query across hundreds of retries. Emit JSON with a fixed schema. At minimum include: correlation_id, event, attempt, requested_model, served_model, provider_request_id, latency_ms, error (when present).

{
  "correlation_id": "b3b1f2c4-7a3e-4d9b-8c1a-2f9e0d4b6a1c",
  "event": "llm_success",
  "attempt": 1,
  "requested_model": "gpt-4o-mini",
  "served_model": "gpt-4o-mini",
  "provider_request_id": "chatcmpl-abc123",
  "latency_ms": 812
}

Pipe these to stdout and let your collector (Vector, Fluent Bit, or OTel collector) ship them. Do not pretty-print in production. Index correlation_id as a keyword field in your log backend. If you use Datadog or Elasticsearch, set the mapping explicitly; a hashed string that gets analyzed as text will break exact-match queries. The same goes for provider_request_id.

Step 4: Implement explicit client-side fallback

Sometimes you want to fall back to a different model when the primary returns a specific error (e.g., context length exceeded). Treat fallback as another attempt and log the switch explicitly.

FALLBACK_MODEL = "gpt-4o"

def complete_with_fallback(self, model, messages):
    try:
        return self.complete(model, messages)
    except openai.BadRequestError as e:
        if "maximum context length" in str(e):
            logger.warning(json.dumps({
                "correlation_id": self.correlation_id,
                "event": "fallback_triggered",
                "from_model": model,
                "to_model": FALLBACK_MODEL,
            }))
            return self.complete(FALLBACK_MODEL, messages)
        raise

This keeps the same correlation_id while recording that the model changed. Your future self will thank you when a prompt unexpectedly blows up on one model but not another.

Step 5: Handle gateway-level fallback

If you route through a gateway such as n4n.ai, automatic fallback when a provider is rate-limited or degraded happens upstream of your code. Your X-Correlation-ID still passes through, but the served_model in the response may differ from the model you requested. Log that discrepancy so you can distinguish gateway fallback from your own client-side fallback.

resp = client.chat.completions.create(
    model="anthropic/claude-3-haiku",
    messages=messages,
    extra_headers={"X-Correlation-ID": correlation_id},
)
if resp.model != "anthropic/claude-3-haiku":
    logger.info(json.dumps({
        "correlation_id": correlation_id,
        "event": "gateway_fallback",
        "requested": "anthropic/claude-3-haiku",
        "served": resp.model,
    }))

Do not assume the gateway will tell you it fell back via a header. Some return the served model only in the response body. Always diff requested vs served rather than trusting a flag. Correlating request IDs across LLM retries and fallbacks becomes trivial when the gateway returns the served model and you already log it on every attempt.

Step 6: Verify end-to-end with a test harness

You cannot claim correlation works until you force a failure and watch the logs. Write a pytest that points the client at a bad API key or a model that does not exist, then asserts that all log records share the same correlation_id.

import json, logging
from unittest.mock import patch, MagicMock
import pytest
import openai

def mock_response():
    class R:
        headers = {"x-request-id": "req-err-1"}
        status_code = 429
    return R()

def test_correlation_id_preserved(caplog):
    with patch("openai.OpenAI") as mock_client:
        mock_client.return_value.chat.completions.create.side_effect = [
            openai.RateLimitError("rate", response=mock_response(), body=None),
            MagicMock(id="chatcmpl-2", model="gpt-4o-mini"),
        ]
        client = RetryLLMClient("https://api.example.com/v1", "bad", "test-id-123")
        client.complete("gpt-4o-mini", [{"role": "user", "content": "hi"}])
        ids = {json.loads(r.message)["correlation_id"] for r in caplog.records}
        assert ids == {"test-id-123"}

The caplog fixture captures records at the root logger; ensure your logger propagates. Run it with pytest -s and tail your local JSON log file. Search for the correlation ID:

grep "test-id-123" app.log | jq '.event'

You should see llm_error followed by llm_success (or fallback_triggered if you forced that path). If every line for that ID appears under one grep, your correlation is solid.

Verification checklist

  • Every outbound LLM request includes X-Correlation-ID.
  • Each retry and fallback emits a JSON log with the same correlation_id.
  • Provider request IDs are captured on both success and error.
  • Gateway-level model switches are logged as gateway_fallback.
  • A single grep on the ID reconstructs the full attempt chain.

Correlating request IDs across LLM retries is not optional once you run more than one model in production. Build it at the edge, propagate it relentlessly, and log it as structured data. The next time a provider melts down, you will know exactly which user request suffered and how many attempts it took to recover.

Tagsstructured-loggingrequest-idretriesfallback

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All structured logging for llm apis posts →