PBM API Sync & Rate Limiting

Synchronous API interactions form the control plane for real-time Pharmacy Benefit Manager (PBM) adjudication. When a pharmacy transmits a live B1 billing request, synchronous endpoints resolve eligibility, verify formulary tier placement, calculate copay and coinsurance, and return an immediate adjudication status while the patient waits at the counter. This area of the Claims Ingestion & NCPDP Parsing pipeline solves one narrow but load-bearing sub-problem: how to push high-volume NCPDP D.0 traffic across a vendor API without exhausting throughput ceilings that trigger HTTP 429 throttling, cascading adjudication failures, and inflated pharmacy help-desk volume. The engineering discipline is to treat API throughput not as an infinite resource but as a constrained queue that requires explicit backpressure, deterministic retry classification, and PHI-safe telemetry on every transmission.

Prerequisites and upstream dependencies

This workflow assumes the claim has already been framed, parsed, and structurally validated upstream, so the rate-limiting tier only ever transmits well-formed payloads. Before any request leaves the edge gateway, three upstream stages must have run:

  • Segment parsing — the raw positional D.0 wire format is decomposed into discrete field codes by the NCPDP D.0 Message Parsing Strategies layer, so that 101-A1 BIN Number, 104-A4 PCN, 301-C1 Group ID, and 407-D7 Product/Service ID (NDC) are available as typed fields rather than substrings.
  • Schema validation — the Schema Validation & Error Categorization stage has rejected malformed transactions locally, so the network is never spent on payloads that will fail structurally at the payer.
  • NDC normalization — the submitted 407-D7 NDC has been resolved through the NDC-to-GPI Crosswalk Automation pipeline, guaranteeing that only a recognized product identifier consumes a request slot.

Environment assumptions for the reference implementation: Python 3.11+, aiohttp 3.9+ for non-blocking I/O, and pydantic 2.x for request modeling and coercion. The rate limiter shares an event loop with the async ingestion workers described in Asynchronous Batch Adjudication Workflows; it must never block that loop. A low-latency key-value store (Redis or equivalent) is expected for idempotency keys and negative-result caching.

Rate-limit rules and response classification

The core algorithm is a deterministic mapping from transport-level response to an ingestion action. Indiscriminate retrying is the single most common failure mode: it burns quota, degrades the vendor trust score, and can convert a transient blip into a self-inflicted outage. Each response class routes to exactly one action and one NCPDP 511-FB Reject Code where a claim-level reject is warranted.

HTTP status Meaning Trips breaker? Retry policy NCPDP 511-FB mapping
200 Adjudicated (paid or clinically rejected) No — records success None Payer-supplied reject code passed through
429 Rate limited / burst exceeded No Honor Retry-After, else backoff + jitter None — resubmit same claim
408 / timeout Gateway did not respond in SLA Yes Bounded retry with backoff 99 (Host Processing Error) after exhaustion
500 / 502 / 504 Upstream server fault Yes Bounded retry with backoff 99 after exhaustion
503 Capacity exhaustion / maintenance Yes Honor Retry-After, backoff 99 after exhaustion
401 Credential expired mid-batch No Refresh token once, replay None — transparent to claim
404 Invalid routing (BIN/PCN/NDC mismatch) No None — deterministic client error 04 (M/I Processor Control Number) or 70 (Product Not Covered)
Other 4xx Deterministic client error No None — dead-letter Mapped per payer companion guide

The token-bucket limiter reads three vendor headers on every response to stay ahead of the ceiling rather than reacting to 429 after the fact: X-RateLimit-Limit (window capacity), X-RateLimit-Remaining (quota left in the current window), and Retry-After (server-directed cooldown in seconds). Vendor burst allowances and sliding-window semantics frequently differ from textbook 429 behavior, so the limiter treats these headers as authoritative and adapts its refill rate to observed X-RateLimit-Remaining decay.

Token-bucket rate limiter and circuit-breaker gate feeding a PBM adjudication endpoint Async B1 claim workers draw from a token bucket of capacity 50 that refills at ten tokens per second. Each claim consumes one token; when the bucket is empty the worker awaits 0.1 seconds as backpressure. Admitted claims pass a circuit-breaker gate — when the breaker is OPEN the request fails fast — before reaching the PBM /adjudicate endpoint. The endpoint returns an X-RateLimit-Remaining header that feeds back to slow the bucket refill rate before the window is exhausted rather than after a 429. Async workers B1 requests refill · 10 tok/s (adaptive) Token bucket capacity 50 consume 1 token else await 0.1s Circuit breaker CLOSED · OPEN · HALF-OPEN OPEN ⇒ fail fast PBM adjudication /adjudicate admit if closed response header X-RateLimit-Remaining → slow refill as quota decays

Figure: The token bucket meters outbound B1 traffic, the circuit breaker gates a failing endpoint, and the vendor's X-RateLimit-Remaining header adapts the refill rate before a 429 is ever returned.

Reference implementation: token-bucket adjudication client

The following implementation is a production-ready, async-compatible client with token-bucket rate limiting, exponential backoff, and circuit-breaker state management. It validates the outbound request with Pydantic against the NCPDP fields required for routing, redacts PHI before any log line is emitted, and never buffers raw claim bytes. Monetary response fields are parsed with decimal.Decimal so copay and coinsurance values are never subjected to binary floating-point drift.

python
import asyncio
import time
import logging
from decimal import Decimal
from typing import Any, Optional
import aiohttp
from dataclasses import dataclass, field
from pydantic import BaseModel, Field, field_validator

logger = logging.getLogger("pbm.adjudication.sync")

# --- PHI-safe request model ------------------------------------------------
# Only the fields required to route the claim are modeled here. 302-C2 Cardholder
# ID and 310-CA Patient First Name are PHI: they travel in the payload but are
# NEVER written to logs or telemetry (see redact() below).
class ClaimRequest(BaseModel):
    transaction_code: str = Field(alias="103-A3")   # 103-A3 Transaction Code (e.g. "B1")
    bin_number: str = Field(alias="101-A1")          # 101-A1 BIN Number (routing)
    pcn: str = Field(alias="104-A4")                 # 104-A4 Processor Control Number
    group_id: str = Field(alias="301-C1")            # 301-C1 Group ID
    cardholder_id: str = Field(alias="302-C2")       # 302-C2 Cardholder ID (PHI)
    ndc: str = Field(alias="407-D7")                 # 407-D7 Product/Service ID (NDC-11)
    date_of_service: str = Field(alias="401-D1")     # 401-D1 Date of Service (CCYYMMDD)

    @field_validator("ndc")
    @classmethod
    def ndc_must_be_11_digits(cls, v: str) -> str:
        if not (v.isdigit() and len(v) == 11):
            raise ValueError("407-D7 NDC must be normalized to 11 digits pre-flight")
        return v

    model_config = {"populate_by_name": True}


def redact(req: ClaimRequest) -> dict[str, str]:
    """Return a log-safe projection: routing keys only, PHI stripped.
    302-C2 Cardholder ID / 310-CA are dropped immediately after routing."""
    return {
        "txn": req.transaction_code,
        "bin": req.bin_number,
        "pcn": req.pcn,
        "ndc": req.ndc,                       # NDC is not PHI; safe to log
        "cardholder": "***REDACTED***",       # 302-C2 never leaves memory in logs
    }


@dataclass
class TokenBucket:
    capacity: float
    refill_rate: float  # tokens per second
    tokens: float = field(init=False)
    last_refill: float = field(init=False, default_factory=time.monotonic)

    def __post_init__(self) -> None:
        self.tokens = self.capacity

    def consume(self, tokens: int = 1) -> bool:
        now = time.monotonic()
        elapsed = now - self.last_refill
        self.tokens = min(self.capacity, self.tokens + (elapsed * self.refill_rate))
        self.last_refill = now
        if self.tokens >= tokens:
            self.tokens -= tokens
            return True
        return False


class CircuitBreaker:
    def __init__(self, failure_threshold: int = 5, recovery_timeout: float = 30.0):
        self.failure_count = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.last_failure_time = 0.0
        self.state = "CLOSED"

    def record_failure(self) -> None:
        self.failure_count += 1
        self.last_failure_time = time.monotonic()
        if self.failure_count >= self.failure_threshold:
            self.state = "OPEN"
            logger.warning("Circuit breaker OPEN after consecutive endpoint failures")

    def record_success(self) -> None:
        self.failure_count = 0
        self.state = "CLOSED"

    def allow_request(self) -> bool:
        if self.state == "CLOSED":
            return True
        if self.state == "OPEN":
            if time.monotonic() - self.last_failure_time > self.recovery_timeout:
                self.state = "HALF-OPEN"
                return True
            return False
        # HALF-OPEN: admit probes until a success closes the breaker or a
        # failure (via record_failure) re-opens it.
        return True


class PBMAdjudicationClient:
    def __init__(self, base_url: str, api_key: str,
                 bucket_capacity: int = 50, refill_rate: float = 10.0,
                 max_retries: int = 4):
        self.base_url = base_url
        self.api_key = api_key
        self.rate_limiter = TokenBucket(capacity=bucket_capacity, refill_rate=refill_rate)
        self.circuit_breaker = CircuitBreaker()
        self.max_retries = max_retries
        self.session: Optional[aiohttp.ClientSession] = None

    async def __aenter__(self) -> "PBMAdjudicationClient":
        self.session = aiohttp.ClientSession()
        return self

    async def __aexit__(self, *exc: Any) -> None:
        if self.session:
            await self.session.close()

    def _adapt_refill(self, remaining: Optional[str]) -> None:
        # Slow refill as the vendor's X-RateLimit-Remaining decays toward zero,
        # so we back off before the window is exhausted rather than after a 429.
        if remaining is None:
            return
        try:
            left = int(remaining)
        except ValueError:
            return
        if left < 5:
            self.rate_limiter.refill_rate = max(1.0, self.rate_limiter.refill_rate * 0.5)

    async def submit_claim(self, req: ClaimRequest, attempt: int = 0) -> dict[str, Any]:
        if not self.circuit_breaker.allow_request():
            raise RuntimeError("Circuit breaker OPEN: PBM endpoint unavailable")

        # Backpressure: yield the event loop until a token is available so other
        # concurrent adjudications keep flowing instead of blocking.
        while not self.rate_limiter.consume():
            await asyncio.sleep(0.1)

        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            # Stable idempotency key: same claim replays never double-adjudicate.
            "Idempotency-Key": f"{req.bin_number}:{req.cardholder_id}:{req.ndc}:{req.date_of_service}",
        }
        # by_alias emits NCPDP field codes as the wire keys the payer expects.
        payload = req.model_dump(by_alias=True)

        try:
            async with self.session.post(
                f"{self.base_url}/adjudicate", json=payload, headers=headers
            ) as response:
                self._adapt_refill(response.headers.get("X-RateLimit-Remaining"))
                retry_after = response.headers.get("Retry-After")

                if response.status == 200:
                    self.circuit_breaker.record_success()
                    body = await response.json()
                    # Parse monetary results as Decimal — never float — so
                    # copay/coinsurance math downstream stays cent-exact.
                    if "patient_pay_amount" in body:            # 505-F5 Patient Pay Amount
                        body["patient_pay_amount"] = Decimal(str(body["patient_pay_amount"]))
                    logger.info("Adjudicated", extra={"claim": redact(req)})
                    return body

                if response.status == 429:
                    # Throttle signal, not an endpoint failure — leave breaker closed.
                    delay = float(retry_after) if retry_after else min(2 ** attempt, 30)
                    logger.info("Rate limited; backing off %.2fs", delay,
                                extra={"claim": redact(req)})
                    await asyncio.sleep(delay)
                    return await self.submit_claim(req, attempt + 1)

                if response.status == 401:
                    # Credential drift mid-batch: refresh once and replay.
                    raise CredentialExpired()

                if response.status in (408, 500, 502, 503, 504):
                    self.circuit_breaker.record_failure()
                    if attempt < self.max_retries:
                        delay = float(retry_after) if retry_after else min(2 ** attempt, 30)
                        await asyncio.sleep(delay)
                        return await self.submit_claim(req, attempt + 1)
                    raise aiohttp.ClientError(f"Server error {response.status} after retries")

                # Deterministic 4xx (404, 400): do not retry, do not trip breaker.
                # Surface to the caller for dedup / dead-letter routing.
                raise aiohttp.ClientResponseError(
                    response.request_info, response.history,
                    status=response.status, message="Client error",
                )
        except aiohttp.ClientResponseError:
            raise  # already classified; breaker untouched
        except CredentialExpired:
            raise
        except Exception as e:
            self.circuit_breaker.record_failure()
            # Log the exception TYPE only — never the payload or PHI.
            logger.error("Transmission failed: %s", type(e).__name__,
                         extra={"claim": redact(req)})
            raise


class CredentialExpired(Exception):
    """Raised on 401 so the caller can refresh the vault token and replay."""
submit_claim decision tree: circuit breaker, token bucket, and HTTP status classification Execution starts at submit_claim. The circuit breaker is checked first: OPEN raises PBM endpoint unavailable. Otherwise the token bucket is checked: no token loops through await asyncio.sleep(0.1) back to the check; a token proceeds to POST /adjudicate. The response status is classified into four terminal actions: 200 records success and returns the JSON body; 429 backs off honoring Retry-After and resubmits the same claim; 5xx or 408 records a failure that trips the breaker and raises; any other 4xx such as 400 or 404 raises a client error without retry and without tripping the breaker. submit_claim(payload) Circuit breaker allows request? Circuit OPEN → raise, unavailable Token bucket has a token? await asyncio .sleep(0.1s) POST /adjudicate HTTP status? record_success() return JSON body back off (Retry-After) resubmit claim record_failure() trip breaker · raise raise client error no retry · no trip No · OPEN Yes No retry Yes 200 429 5xx / 408 4xx resubmit same claim

Figure: Token-bucket gate and circuit breaker wrapping a PBM adjudication call, with 5xx tripping the breaker and 429 backing off

Engineering constraints and known failure modes

Rate limiting sits on the seam between the ingestion tier and an external system you do not control, so its failure modes are almost all about misclassifying an ambiguous response.

  • 429 vs 503 conflation. Some payer gateways return 503 for enforced rate limiting rather than 429. Treating every 503 as an endpoint fault trips the breaker unnecessarily and starves the live POS path. Distinguish them by the presence of Retry-After plus X-RateLimit-Remaining: 0, and route to backoff rather than the failure counter. The full decision tree for these two codes is worked through in Handling PBM 404 and 503 errors in adjudication scripts.
  • 404 as a routing signal, not a missing endpoint. In PBM ecosystems a 404 almost never means a missing REST resource; it means an invalid 101-A1/104-A4 BIN/PCN combination or an 407-D7 NDC that is not in the payer’s active directory. Retrying wastes quota. Map it to 511-FB reject 04 or 70, cache the failed key, and dead-letter for resubmission.
  • Credential drift mid-batch. A 401 during a long batch means the vault-issued token expired between the session opening and this request. Do not trip the breaker — refresh the token once, replay the claim, and continue. Serializing that refresh across workers is the subject of Automating PBM Portal Credential Rotation: fetch once, buffer in memory with a pre-expiry margin (typically 60 seconds), and force-invalidate on 401.
  • Breaker recovery storms. When the breaker moves to HALF-OPEN, admitting the entire backlog at once re-overloads a recovering endpoint. Admit a bounded number of probe requests before declaring the endpoint healthy.
  • Recursion depth on repeated 429. The retry path is recursive; without an attempt cap a persistently throttled window can build a deep call stack. The max_retries bound and exponential ceiling (min(2 ** attempt, 30)) prevent that.

The circuit-breaker mechanics used here are shared with the broader Fallback Routing Logic Design area; see Implementing Circuit Breakers for PBM API Timeouts for the timeout-driven variant.

Performance and correctness tuning

  • Negative-result caching. Cache failed BIN/PCN and NDC-to-GPI combinations in Redis with a short TTL so a bad routing key does not generate a fresh 404 on every retry cycle. This is the single largest reduction in wasted quota under high-volume ingestion.
  • Idempotency keys. The Idempotency-Key header is derived deterministically from 101-A1 + 302-C2 + 407-D7 + 401-D1. Any resubmission of the same claim — after a timeout, a breaker recovery, or a worker restart — is de-duplicated at the payer, so a network retry can never double-adjudicate a paid claim.
  • Decimal precision. All monetary response fields (505-F5 Patient Pay Amount, 506-F6 Ingredient Cost Paid, coinsurance) are parsed as decimal.Decimal, never float. Binary floating point cannot represent common cent values exactly, and a one-cent drift on a reconciled claim is an audit finding.
  • SLA budgeting. Point-of-sale adjudication is latency-critical: a patient is waiting. Budget total wall-clock time (token wait + request + retries) against the pharmacy SLA and prefer failing fast to a dead-letter queue over exhausting a long backoff chain on the live path. Reserve the deep-retry chains for the asynchronous reprocessing lane.
  • Telemetry. Emit X-RateLimit-Remaining decay curves, backoff-latency distributions, and breaker state transitions to your observability stack — never the payload. These signals feed capacity planning and vendor contract negotiation. Every log line uses the redact() projection so 302-C2 and other PHI never reach disk, satisfying the constraints in Security & Compliance Boundaries for Claims Data.

In-depth guides in this area

For protocol specifications on transaction standards, consult the official NCPDP Telecommunication Standard. For concurrency patterns, reference the official asyncio documentation.

← Back to Claims Ingestion & NCPDP Parsing