Secretary Of State Portal Api Ingestion

Implementing Exponential Backoff for Secretary of State APIs

This guide is part of the Async Polling & Rate Limiting area within the Secretary of State Portal & API Ingestion framework: it drills into the single retry primitive that every status sweep, good-standing check, and annual-report submission depends on — how to space retries against a throttling, asynchronous government portal without either blacklisting your egress or overrunning a statutory deadline.

Scope of This Page

This page covers the backoff algorithm itself: how to compute the next wait interval, how to honour a portal-supplied Retry-After over your own schedule, how to bound the total wait against the entity’s remaining compliance window, and how to record every attempt in a tamper-evident chain. It deliberately excludes the surrounding machinery documented elsewhere — the per-jurisdiction token bucket that paces steady-state traffic, the deterministic Error Categorization & Retry Logic taxonomy that decides whether a response is retryable at all, and the headless browser fallback chain that takes over when an API endpoint degrades past use. Here we assume a request has already been classified as transiently retryable and focus only on when to fire the next attempt.

The Constraint That Forces Deadline-Aware Backoff

A 429 Too Many Requests from a state portal is not a network nuisance — it is an event on a statutory clock. Delaware corporations owe their franchise-tax and annual report by March 1 under 8 Del. C. § 502, with a $200 penalty plus 1.5% monthly interest accruing under § 510 before administrative action. California’s biennial Statement of Information is governed by Cal. Corp. Code § 1502, with a $250 penalty under § 2204 and suspension of corporate powers under § 2205. Because these penalties trigger on the filing clock and not the polling clock, naive backoff is actively dangerous: an unbounded exponential schedule that doubles to 256 seconds, then 512, can push a delinquent entity’s status check past its cure window while your code believes it is “being polite.” Every wait interval computed here is therefore capped against the remaining compliance window read from the State Filing Deadline Calendars layer, and a deadline that falls inside the grace period escalates to a low-latency channel rather than waiting out a long backoff.

Prerequisites

  • Python 3.10+ — for X | Y unions, match on status classes, and modern asyncio.
  • httpx 0.27+ — async HTTP with connection pooling and a typed Response/Request model.
  • Standard library only beyond that: asyncio, hashlib, json, logging, random, time, email.utils (for HTTP-date Retry-After parsing).
  • An append-only audit sink (write-once Postgres table, or object storage with Object Lock) and a dead-letter queue for retry-exhausted fallout.
  • A pre-classified, single-intent request whose response has already been judged transiently retryable by the parent area’s error taxonomy.

Implementation: A Deadline-Bounded Backoff Engine

The module below computes wait intervals with decorrelated jitter — the AWS-recommended variant that randomises across the full window between the base delay and the previous wait, which avoids both the thundering-herd synchronisation of fixed backoff and the runaway upper intervals of pure exponential growth. A portal-supplied Retry-After always wins over the computed schedule, every wait is clamped to the entity’s remaining grace seconds, and each attempt is hashed into a chain so the audit trail is tamper-evident. Comments mark the compliance-critical lines.

One retryable response through the deadline-bounded backoff engine: Retry-After versus decorrelated jitter, the grace-window compliance guard, and the per-attempt audit chain A retryable response branches on whether a Retry-After header is present (honour the parsed hint) or absent (compute decorrelated jitter), producing a wait. A compliance guard escalates to a priority channel if the wait would breach the remaining grace window; otherwise the engine sleeps and retries until max_attempts, then falls out to a dead-letter queue. Each attempt appends one sha256-chained, tamper-evident audit record. Deadline-bounded backoff: compute a wait, then never sleep past the statutory clock Retryable response 429 · 502 · 503 · 504 Retry-After header present? RFC 7231 §7.1.3 · portal wins no Decorrelated jitter min(cap, uniform(base, prev×3)) de-syncs the worker fleet yes Honour portal hint parse delta-secs | HTTP-date min(cap, retry_after) wait (seconds, honored) spent + wait > grace_seconds ? COMPLIANCE GUARD · statutory cure window yes ESCALATE → low-latency priority channel never sleep past the deadline no attempt < max_attempts ? bounded loop · budget cannot run away yes sleep(wait) · attempt++ append audit record ↻ retry next attempt no FALLOUT → dead-letter queue · forensic replay Every attempt appends one record → tamper-evident audit chain sha256(prev ‖ fields) rec n−1 #a17f… rec n #c3d9… rec n+1 #e5b2… prev_hash prev_hash
from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import random
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from enum import Enum
from typing import Optional

import httpx

# Structured JSON logging — every line is a parseable audit/observability event.
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("ingestion.backoff")


class Resolution(str, Enum):
    SUCCESS = "success"        # portal returned a final 2xx
    ESCALATED = "escalated"    # backoff would breach the grace window — divert to priority channel
    FALLOUT = "fallout"        # retries exhausted — routed to dead-letter queue


@dataclass(frozen=True)
class BackoffPolicy:
    base_delay: float = 1.0    # first-retry floor, seconds
    cap: float = 60.0          # per-wait ceiling; never sleep longer than this on one attempt
    multiplier: float = 3.0    # decorrelated-jitter growth factor (prev_wait * multiplier)
    max_attempts: int = 5      # bounds the loop so a degraded portal cannot burn the budget


@dataclass(frozen=True)
class AttemptRecord:
    attempt: int
    timestamp_utc: str
    http_status: Optional[int]
    wait_applied: float
    retry_after_honored: bool
    prev_hash: str             # chains to the previous attempt for tamper evidence
    this_hash: str

    @staticmethod
    def chain(prev_hash: str, attempt: int, status: Optional[int],
              wait: float, honored: bool) -> "AttemptRecord":
        ts = datetime.now(timezone.utc).isoformat()
        body = json.dumps(
            {"prev": prev_hash, "attempt": attempt, "status": status,
             "wait": round(wait, 3), "honored": honored, "ts": ts},
            sort_keys=True,
        )
        digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
        return AttemptRecord(attempt, ts, status, round(wait, 3), honored, prev_hash, digest)


def parse_retry_after(value: Optional[str]) -> Optional[float]:
    """A portal's Retry-After is authoritative; honor it over any computed schedule.

    The header is legally either delta-seconds OR an HTTP-date (RFC 7231 §7.1.3).
    Parse both; an unparseable header falls back to None so the caller computes its own wait.
    """
    if not value:
        return None
    if value.strip().isdigit():
        return float(value.strip())
    try:
        when = parsedate_to_datetime(value)
        if when.tzinfo is None:
            when = when.replace(tzinfo=timezone.utc)
        return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
    except (TypeError, ValueError):
        return None


class BackoffEngine:
    """Deadline-bounded exponential backoff with decorrelated jitter and an audit chain."""

    # Statuses the engine will wait-and-retry; everything else is the caller's concern.
    RETRYABLE = frozenset({429, 502, 503, 504})

    def __init__(self, client: httpx.AsyncClient, policy: BackoffPolicy | None = None) -> None:
        self._client = client
        self._policy = policy or BackoffPolicy()
        self._trail: list[AttemptRecord] = []

    def _next_wait(self, prev_wait: float, retry_after: Optional[float]) -> tuple[float, bool]:
        # A portal-supplied Retry-After always overrides our computed schedule.
        if retry_after is not None:
            return min(retry_after, self._policy.cap), True
        # Decorrelated jitter: uniform across [base, prev_wait * multiplier], capped.
        upper = max(self._policy.base_delay, prev_wait * self._policy.multiplier)
        wait = min(self._policy.cap, random.uniform(self._policy.base_delay, upper))
        return wait, False

    async def run(self, request: httpx.Request, grace_seconds: float) -> Resolution:
        """Execute `request` with backoff, never spending more than `grace_seconds` total."""
        prev_hash = "GENESIS"
        prev_wait = self._policy.base_delay
        spent = 0.0

        for attempt in range(1, self._policy.max_attempts + 1):
            response = await self._client.send(request)
            if response.status_code not in self.RETRYABLE:
                response.raise_for_status()  # 4xx terminal errors surface to the caller
                self._append(prev_hash, attempt, response.status_code, 0.0, False)
                return Resolution.SUCCESS

            retry_after = parse_retry_after(response.headers.get("Retry-After"))
            wait, honored = self._next_wait(prev_wait, retry_after)

            # COMPLIANCE GUARD: if waiting would consume the cure window, do not sleep —
            # escalate the obligation to the priority channel instead of risking a penalty.
            if spent + wait > grace_seconds:
                self._append(prev_hash, attempt, response.status_code, wait, honored)
                logger.warning(json.dumps({
                    "event": "deadline_escalation", "attempt": attempt,
                    "would_spend": round(spent + wait, 2), "grace": grace_seconds,
                }))
                return Resolution.ESCALATED

            record = self._append(prev_hash, attempt, response.status_code, wait, honored)
            prev_hash, prev_wait, spent = record.this_hash, wait, spent + wait
            await asyncio.sleep(wait)

        # Retries exhausted within budget: never silently drop — preserve for forensic replay.
        logger.error(json.dumps({"event": "backoff_exhausted",
                                 "attempts": self._policy.max_attempts}))
        return Resolution.FALLOUT

    def _append(self, prev_hash: str, attempt: int, status: Optional[int],
                wait: float, honored: bool) -> AttemptRecord:
        record = AttemptRecord.chain(prev_hash, attempt, status, wait, honored)
        self._trail.append(record)
        logger.info(json.dumps({
            "event": "backoff_attempt", "attempt": attempt, "status": status,
            "wait": record.wait_applied, "retry_after_honored": honored,
            "hash": record.this_hash[:12],
        }))
        return record

The engine is deliberately a leaf: it makes no classification decision and performs no fallback. It consumes an already-retryable request, returns a typed Resolution — succeeded, escalated, or fallout — and leaves the verdict to the surrounding orchestration. The synthetic-599 workaround that earlier revisions needed is gone; surfacing a typed Resolution is cleaner than fabricating an httpx.Response from a dummy request.

Configuration Reference

Backoff parameters are configuration data, never branches in the retry loop, because they are dictated by each registry’s enforced behaviour and by the statutory clock rather than by your code.

Parameter Suggested value Legal / operational justification
base_delay 1.0 s Floor for the first retry; below ~0.5 s a recovering portal sees a near-immediate re-hit.
cap 60 s Per-wait ceiling. A single sleep beyond a minute risks overshooting tight cure windows (CA § 2205).
multiplier 3.0 Decorrelated-jitter growth; 3× spreads load wider than plain doubling without runaway tails.
max_attempts 5 Bounds the loop so a degraded portal cannot consume the failure budget indefinitely.
grace_seconds per-entity Remaining cure window from State Filing Deadline Calendars; total wait is clamped to it.
RETRYABLE set 429/502/503/504 Only transient faults back off; 4xx is terminal and surfaces immediately.
Retry-After precedence portal wins RFC 7231 §7.1.3 — an explicit server hint overrides any client-computed schedule.

Failure Modes and Fallback Routing

Each fault maps onto the four-tier scheme defined in the parent area’s error categorization & retry logic — transient, statutory, data-validation, and system — and the engine responds to each differently.

  1. Sustained 429 / 503 with a long Retry-After (transient, but deadline-threatening). The portal is honestly signalling a multi-minute pause. The engine parses the header, but if honouring it would push total wait past grace_seconds, it returns ESCALATED rather than SUCCESS-by-sleeping. The orchestrator diverts the obligation to a low-concurrency priority channel so the statutory clock is respected even while the bulk sweep waits.
  2. Retry-After as an HTTP-date in the past or unparseable garbage (system). Legacy ASP.NET and Java portals occasionally emit malformed or stale dates. parse_retry_after returns 0.0 for a past date and None for garbage, so the engine falls back to its own decorrelated-jitter schedule instead of crashing or sleeping forever.
  3. 503 during a January–March maintenance window, retries exhausted (system/fallout). A portal down for the season survives the full max_attempts loop. The engine returns FALLOUT; the dead-letter consumer replays the request later under a human-supervised path, and ingestion fails over to the last cached compliance snapshot rather than recording a false status.
  4. A 400/404 slips into the loop (data-validation/terminal). These are never in RETRYABLE; raise_for_status() surfaces them immediately so the caller can quarantine the malformed entity against its compliance metadata schemas instead of wasting retries on a request that can never succeed.

Frequently Asked Questions

Why decorrelated jitter instead of plain exponential backoff with a fixed jitter band?

Plain exponential backoff with a small jitter band still synchronises a fleet of compliance workers: every worker that hit the same portal at the same second computes nearly the same next wait, so they all re-hit together and re-trip the rate limiter. Decorrelated jitter draws each wait uniformly from [base, prev_wait * multiplier], which spreads retries across the whole window and de-correlates the workers. Against a single recovering government portal that is the difference between a clean recovery and a second outage.

The portal sent a `Retry-After` of 300 seconds but my entity is due tomorrow — do I just wait?

No. The engine honours Retry-After over its own schedule, but only up to the cap, and it still clamps total wait to grace_seconds. If sleeping the requested interval would consume the cure window, run() returns ESCALATED instead of sleeping, and the orchestrator moves the obligation to a priority channel. Respecting a portal’s pacing hint must never override a statutory deadline — penalties accrue on the filing clock, not the polling clock.

How does the audit chain prove the retries actually happened the way the log claims?

Each AttemptRecord hashes its own fields together with the previous record’s digest, so the records form a chain in which altering or deleting any attempt invalidates every hash after it. Flushed to write-once storage (Postgres append-only or S3 with Object Lock), the chain lets counsel demonstrate, attempt by attempt, exactly when each request fired, what status it received, and how long the engine waited — the standard of evidence a regulatory examination expects.

Should this engine share the per-jurisdiction token bucket from the parent area?

They are complementary, not redundant. The token bucket in Async Polling & Rate Limiting paces steady-state traffic so you rarely earn a 429 in the first place; this engine governs recovery once a throttle or server fault has already occurred. In production the request acquires a token before its first send, and the backoff engine takes over only after a retryable status comes back. Running backoff without the bucket means you throttle constantly; running the bucket without backoff means a transient 503 drops the obligation.