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 | Yunions,match-style branching, andfrozensetliterals. - 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.
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.
- A
200HTML 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 notjson.loadsand carries nox-schema-versionclassifies asSYSTEM, routing to schema remediation and the headless browser fallback strategies transport switch rather than being confirmed as a filing. - A statutory hold wrapped in a
200body (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_MARKERScurrent with each registry’s exact wording. - A rate-limit response dressed as
403with noRetry-After(transient misread as validation). California’s bizfile returns throttling as a403body phrase rather than a429. Add the throttle phrase to a transient override ahead of the_VALIDATION_STATUScheck for that jurisdiction, or the classifier will halt a retryable request to legal-ops. Once corrected, the response classifiesTRANSIENTand hands to the exponential backoff engine. - A session-expired page returned as
200(data-validation misread as transient). Texas SOSDirect emits a session notice instead of a401. Rule 3’s_SESSION_MARKERScatch 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.