Secretary Of State Portal Api Ingestion

Classifying Transient vs. Terminal Errors From State Portals

This guide is part of the Error Categorization & Retry Logic area within the Secretary of State Portal & API Ingestion framework. It drills into the single decision every downstream policy depends on: the instant a portal responds, is the fault something that will clear on its own if we wait, or something no amount of waiting can fix? That question — transient versus terminal — has to be answered by one deterministic classifier, before any retry, backoff, or fallback runs, so that the rest of the pipeline is driven by a category and never by an ad-hoc status check scattered across call sites.

Scope of This Page

This page covers the classifier itself: how to reduce a raw response — HTTP status, body signature, and headers — into exactly one of four classes, and how each class maps to one routing verdict. The four classes are transient (a momentary transport or server fault, safe to retry), statutory (an administrative or legal block such as a franchise-tax hold or a suspended entity), data-validation (our own request is malformed and retrying it cannot help), and system (structural drift — an HTML error page or an unschematized body where JSON was expected). These are the concrete, machine-decidable form of the four-category taxonomy the parent area defines; this page is where a response actually gets a label. It deliberately excludes what happens after the label is assigned — the exponential backoff engine that paces a retry, the dead-letter queue that absorbs exhausted attempts, and the priority scoring algorithms that consume a statutory classification to rank penalty exposure. Here we only answer which class, and we answer it the same way every time.

Why Status Alone Cannot Decide the Class

A classifier that keys on the HTTP status line will mislabel the single most dangerous response a state portal produces: a 200 OK whose body carries an error. Legacy registries routinely wrap a maintenance notice, a session-expired page, or an administrative hold inside a 200 HTML document — the transport succeeded, the transaction did not. Treating that as success records a filing that never happened, and the entity silently drifts out of good standing while every dashboard shows green. The consequences are statutory, not cosmetic: a California entity that misses its Statement of Information under Cal. Corp. Code § 1502 accrues a $250 penalty under § 2204 and moves toward suspension of corporate powers under § 2205; a Delaware corporation that misses the March 1 franchise-tax and annual-report deadline under 8 Del. C. § 502 accrues penalty and 1.5% monthly interest under § 510. Because those clocks run on the filing, a misclassified 200 is indistinguishable from a real lapse until the penalty arrives. The classifier therefore inspects content before status: an HTML signature or a statutory marker in the body overrides whatever the status line claims.

Prerequisites

  • Python 3.10+ — for X | Y unions, match-style branching, and frozenset literals.
  • Standard library only: json, logging, re, dataclasses, enum. The classifier performs no network I/O, so it carries no HTTP-client dependency.
  • A response already captured as a (status, headers, body) triple by the ingestion layer — the classifier is a pure function over that triple and never issues the request itself.
  • A stable set of per-jurisdiction body markers (the phrases each portal emits for holds, validation rejections, and session expiry), maintained as data and reviewed whenever a portal changes its wording.
  • A downstream router that understands the four verdicts (RETRY, ESCALATE, HALT, REMEDIATE) so a class maps to exactly one action.

Implementation: A Rules-Based, Content-First Classifier

The diagram traces one response through signal extraction and a priority-ordered rule ladder. The ordering is the whole design: structural and content checks run before status-based checks, so a 200 wrapping HTML or a hold phrase is caught by content and never mistaken for success.

A raw portal response through signal extraction and a priority-ordered rule ladder into four classes and four routing verdicts The raw response is reduced to signals — status, content-type, HTML-ness, JSON-ness, schema header, lowercased body — which drive four priority-ordered rules. Content checks run before status checks: HTML or unschematized body is SYSTEM (remediate); a statutory marker is STATUTORY (escalate); a validation status or marker is DATA_VALIDATION (halt); a retryable status is TRANSIENT (retry); anything unmatched defaults conservatively to TRANSIENT. Classify by content first, status last: one response → one class → one verdict Raw response status · headers · body Signal extraction · status code · content-type · looks_like_html · has_json_body · x-schema-version · body (lowercased) pure · no network I/O HTML or no schema/JSON? structural · content before status Statutory marker in body? hold · suspended · dissolved Validation status/marker? 4xx · bad payload · re-auth Retryable status / timeout? 429 · 500 · 502 · 503 · 504 no no no else → conservative TRANSIENT (retry, never drop) yes yes yes yes SYSTEM structural drift STATUTORY legal / admin block DATA_VALIDATION our request is wrong TRANSIENT safe to retry REMEDIATE schema + headless fallback ESCALATE penalty-avoidance queue HALT legal-ops queue RETRY hand to backoff engine

The module below is the complete classifier: a signal extractor and a priority-ordered rule ladder that returns a typed Classification. It is a pure function — no I/O, no clock, no randomness — so the same response always yields the same class, which is exactly the property an audit needs. Comments mark the compliance-critical ordering.

from __future__ import annotations

import json
import logging
import re
from dataclasses import dataclass
from enum import Enum
from typing import Mapping

# Structured JSON logging — one line per classification, machine-parseable for audit.
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("ingestion.error_classifier")


class ErrorClass(str, Enum):
    TRANSIENT = "transient"              # momentary transport/server fault — safe to retry
    STATUTORY = "statutory"             # administrative/legal block — escalate, never retry
    DATA_VALIDATION = "data_validation"  # our request is malformed — retrying cannot help
    SYSTEM = "system"                   # structural drift (HTML/schema) — remediate + switch transport


class Verdict(str, Enum):
    RETRY = "retry"          # hand to the backoff engine
    ESCALATE = "escalate"    # penalty-avoidance queue — a statutory clock is running
    HALT = "halt"            # legal-ops queue — human correction before resubmit
    REMEDIATE = "remediate"  # schema remediation + headless-browser transport switch


# One immutable verdict per class: routing is a lookup, never an ad-hoc branch.
_VERDICT: dict[ErrorClass, Verdict] = {
    ErrorClass.TRANSIENT: Verdict.RETRY,
    ErrorClass.STATUTORY: Verdict.ESCALATE,
    ErrorClass.DATA_VALIDATION: Verdict.HALT,
    ErrorClass.SYSTEM: Verdict.REMEDIATE,
}

# Body signatures outrank status: a 200 can wrap any of these.
_STATUTORY_MARKERS = (
    "franchise tax hold", "entity inactive", "not in good standing",
    "administratively dissolved", "filing moratorium", "suspended",
)
_VALIDATION_MARKERS = (
    "invalid entity id", "missing signature", "fee mismatch",
    "unknown jurisdiction code", "officer signature required",
)
_SESSION_MARKERS = ("session expired", "please sign in", "log in to continue")
_HTML_SIGNATURE = re.compile(r"<!doctype html|<html[ >]", re.IGNORECASE)

# Status is only consulted AFTER content checks fail to fire.
_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
_VALIDATION_STATUS = frozenset({400, 401, 403, 404, 409, 422})


@dataclass(frozen=True)
class Signals:
    status: int
    content_type: str
    looks_like_html: bool
    has_json_body: bool
    schema_version: str | None
    body_lc: str


@dataclass(frozen=True)
class Classification:
    error_class: ErrorClass
    verdict: Verdict
    signal: str   # the rule that fired — the audit trail must record *why*, not just *what*
    status: int


def extract_signals(status: int, headers: Mapping[str, str], body: str | None) -> Signals:
    """Derive every classification input from the raw response; no decisions here."""
    lc_headers = {k.lower(): v for k, v in headers.items()}
    content_type = lc_headers.get("content-type", "").lower()
    raw = body or ""
    stripped = raw.lstrip()
    looks_like_html = bool(_HTML_SIGNATURE.search(raw)) or content_type.startswith("text/html")
    has_json_body = False
    if "json" in content_type or stripped[:1] in "{[":
        try:
            json.loads(raw)
            has_json_body = True
        except (ValueError, TypeError):
            has_json_body = False  # a body that *claims* JSON but will not parse is structural drift
    return Signals(
        status=status,
        content_type=content_type,
        looks_like_html=looks_like_html,
        has_json_body=has_json_body,
        schema_version=lc_headers.get("x-schema-version"),
        body_lc=raw.lower(),
    )


def classify(status: int, headers: Mapping[str, str], body: str | None,
             *, entity_id: str, jurisdiction: str) -> Classification:
    """Reduce a non-success portal response to exactly one class and verdict."""
    s = extract_signals(status, headers, body)

    # RULE 1 (SYSTEM) — content before status. An HTML document, or a 2xx body with no
    # parseable JSON and no schema header, is structural drift, not a real answer.
    if s.looks_like_html or (status < 400 and not s.has_json_body and s.schema_version is None):
        return _decide(ErrorClass.SYSTEM, "html_or_unschematized_body", s, entity_id, jurisdiction)

    # RULE 2 (STATUTORY) — an administrative/legal block, even wrapped in a 200 body.
    if any(m in s.body_lc for m in _STATUTORY_MARKERS):
        return _decide(ErrorClass.STATUTORY, "statutory_marker", s, entity_id, jurisdiction)

    # RULE 3 (DATA_VALIDATION) — a session-expiry page is a re-auth problem, not an outage;
    # so is a 4xx or an explicit rejection phrase. Retrying an unchanged request cannot succeed.
    if (any(m in s.body_lc for m in _SESSION_MARKERS)
            or status in _VALIDATION_STATUS
            or any(m in s.body_lc for m in _VALIDATION_MARKERS)):
        return _decide(ErrorClass.DATA_VALIDATION, "validation_status_or_marker", s, entity_id, jurisdiction)

    # RULE 4 (TRANSIENT) — a genuine server/transport fault. Safe to hand to the backoff engine.
    if status in _RETRYABLE_STATUS:
        return _decide(ErrorClass.TRANSIENT, "retryable_status", s, entity_id, jurisdiction)

    # DEFAULT — nothing matched: treat conservatively as transient so the obligation is
    # retried and never silently dropped from a compliance sweep.
    return _decide(ErrorClass.TRANSIENT, "unclassified_conservative", s, entity_id, jurisdiction)


def _decide(error_class: ErrorClass, signal: str, s: Signals,
            entity_id: str, jurisdiction: str) -> Classification:
    verdict = _VERDICT[error_class]
    logger.info(json.dumps({
        "event": "error_classified",
        "entity_id": entity_id,
        "jurisdiction": jurisdiction,
        "status": s.status,
        "content_type": s.content_type,
        "error_class": error_class.value,
        "verdict": verdict.value,
        "signal": signal,  # exactly which rule fired, for reproducible audit
    }))
    return Classification(error_class, verdict, signal, s.status)

The classifier is a leaf: it neither sleeps nor enqueues nor mutates state. It returns a typed Classification and lets the surrounding orchestration act on the verdict. That separation is what makes it testable against captured response fixtures with no network, and reproducible under audit — replay the persisted (status, headers, body) triple and the class and verdict must come back identical.

Configuration Reference

The rule ladder is fixed, but its inputs are data. Marker lists and status sets are configuration because they track each registry’s wording and enforced behaviour, not your control flow.

Setting Value Justification
Rule order SYSTEM → STATUTORY → DATA_VALIDATION → TRANSIENT Content-bearing checks precede status checks so a 200 wrapping HTML or a hold is never read as success.
_HTML_SIGNATURE <!doctype html / <html> Detects an actual HTML document returned where JSON was expected — the classic legacy-portal 200.
_STATUTORY_MARKERS per-jurisdiction phrases Franchise-tax holds and suspensions surface as body text, not status codes; escalate on the phrase.
_VALIDATION_STATUS 400/401/403/404/409/422 Client-side faults that no retry can fix; route to legal-ops for correction.
_RETRYABLE_STATUS 408/425/429/500/502/503/504 Only genuine transport/server faults back off; everything else is terminal.
Default class TRANSIENT (conservative) An unmatched response is retried, never dropped — a silent drop is the one forbidden outcome.
Confidence field signal string Records which rule fired, so an examiner can reconstruct why a class was assigned.

Failure Modes and Fallback Routing

Each misclassification maps back onto the parent area’s four-category taxonomy in error categorization & retry logic, and each has a distinct remediation.

  1. A 200 HTML maintenance page read as success (system misread as transient/none). This is the failure the ordering exists to prevent. If a portal’s maintenance page omits a <!doctype> and slips past _HTML_SIGNATURE, the JSON-parse guard still fires — a body that will not json.loads and carries no x-schema-version classifies as SYSTEM, routing to schema remediation and the headless browser fallback strategies transport switch rather than being confirmed as a filing.
  2. A statutory hold wrapped in a 200 body (statutory misread as transient). A franchise-tax hold retried as a transient fault burns attempts against a clock it can never beat. Rule 2 runs before any status check precisely so the hold phrase escalates to the penalty-avoidance path, where priority scoring algorithms rank it against the remaining grace window. Keep _STATUTORY_MARKERS current with each registry’s exact wording.
  3. A rate-limit response dressed as 403 with no Retry-After (transient misread as validation). California’s bizfile returns throttling as a 403 body phrase rather than a 429. Add the throttle phrase to a transient override ahead of the _VALIDATION_STATUS check for that jurisdiction, or the classifier will halt a retryable request to legal-ops. Once corrected, the response classifies TRANSIENT and hands to the exponential backoff engine.
  4. A session-expired page returned as 200 (data-validation misread as transient). Texas SOSDirect emits a session notice instead of a 401. Rule 3’s _SESSION_MARKERS catch it and route to re-authentication rather than a futile retry loop; without the marker the classifier would fall through to the conservative transient default and waste the retry budget.

Frequently Asked Questions

Why check the body before the HTTP status at all?

Because a state portal’s status line is not a reliable success signal. Legacy registries return 200 OK while the body carries a maintenance page, a franchise-tax hold, or a session-expired notice. If the classifier trusted status first, it would label those as success and record filings that never happened. Running the structural and statutory content checks ahead of any status-based rule is what stops a wrapped error from being mistaken for a clean response — the single most consequential misclassification in compliance ingestion.

What is the difference between the statutory and data-validation classes if both stop the retry?

They stop the retry for opposite reasons and route to opposite queues. DATA_VALIDATION means our request is wrong — a malformed entity ID, a missing signature, an expired session — so it halts to the legal-ops queue for correction and resubmission. STATUTORY means the portal is asserting a legal or administrative state — the entity is suspended, on a franchise-tax hold, or under a filing moratorium — so it escalates to the penalty-avoidance path, because a statutory clock is already running and the fix is a filing or exemption, not a payload correction. Collapsing them would send a suspended entity to a queue that only re-validates payloads.

How does the classifier stay deterministic and audit-reproducible?

It is a pure function of the (status, headers, body) triple: no clock, no randomness, no network, no shared mutable state. The same response always produces the same class, verdict, and signal string. Every classification is logged as one structured JSON line recording the status, content type, class, verdict, and which rule fired, so an examiner can replay a persisted response fixture and obtain an identical result. Any divergence indicates the marker configuration changed, not that the decision was arbitrary.

Where does a classified response go next?

The classifier only assigns a class and verdict; it never acts. A TRANSIENT/RETRY verdict is handed to the Async Polling & Rate Limiting backoff engine; a STATUTORY/ESCALATE verdict feeds the penalty-avoidance path scored by Priority Scoring Algorithms; a DATA_VALIDATION/HALT verdict goes to the legal-ops queue; and a SYSTEM/REMEDIATE verdict triggers schema remediation and a transport switch. Attempts that exhaust their retries after a transient classification land in the dead-letter queue for replay.