Resubmitting rejected ABI entries idempotently
A rejected ABI/ACE entry has to go back to CBP, but the retry path is where brokers accidentally file the same entry twice — a duplicate that triggers a duplicate-entry-number rejection at best and a second bond obligation at worst. The fix is idempotency: every resubmission is keyed so that transmitting it more than once is indistinguishable, to CBP and to your own ledger, from transmitting it once. This page builds that discipline in Python — a stable dedup key derived from the entry’s identity, a per-attempt idempotency token, a persisted transmission ledger that gates the wire, retry with exponential backoff, and reconciliation of the eventual ACK or reject. It is the direct sequel to decoding the X12 824 that told you the entry was rejected in the first place, and it leans on the same error-handling and retry patterns used elsewhere in the pipeline.
The problem, precisely
ABI transmission is not transactional. When you send an entry summary and the connection drops, you do not know whether CBP received it. Naively retrying can file a duplicate; naively not retrying can strand a valid correction. And a rejected entry adds a second hazard: the corrected resubmission must be recognizable as the same logical filing, not a brand-new one, so that CBP’s disposition and your ledger stay in agreement. Idempotency resolves both. A resubmission carries a dedup key that is a pure function of the entry’s stable identity (entry number, filer code, entry type, and a content hash of the correctable payload). The ledger records intent before the wire and disposition after, so a crash between the two is recoverable rather than a mystery. The wire is never touched unless the ledger says this exact attempt has not already reached a terminal state.
Prerequisites
- Python 3.10+ with
dataclass,Enum,Optional, andDecimalfor any monetary field that rides along in the payload. Never usefloatfor entered value or duty. - A durable ledger store — PostgreSQL is assumed here, accessed via asyncpg. The ledger needs a unique constraint on the dedup key so a duplicate insert is rejected by the database, not merely by application logic.
- The rejection already decoded. You must know the entry was refused and whether the fault was hard (correct then resubmit) or soft (no resubmission needed) — that verdict comes from the 824 application advice parser. Do not resubmit on a soft advisory.
- A monotonic clock and a UUID source for idempotency tokens and backoff scheduling. Backoff timestamps must come from a timezone-aware clock, never a naive local one.
- Structured logging (stdlib
loggingwith%(asctime)s | %(levelname)s | %(name)s | %(message)s) keyed by dedup key and control number, so every attempt is auditable under 19 CFR 163.
Data contract
Two records govern the flow. The ResubmitRequest is the immutable intent; the LedgerEntry is the mutable-once state machine the database enforces.
CREATE TABLE abi_transmission_ledger (
dedup_key TEXT PRIMARY KEY, -- stable entry identity + content hash
entry_number TEXT NOT NULL,
idempotency_token UUID NOT NULL, -- unique per attempt
state TEXT NOT NULL, -- PENDING | SENT | ACKED | REJECTED
attempt INTEGER NOT NULL DEFAULT 0,
control_number TEXT, -- ISA/GS/ST of the sent interchange
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The primary key on dedup_key is the single most important line: the database, not the application, guarantees that one logical resubmission can occupy the ledger only once.
Implementation
The resubmitter computes a dedup key, claims a ledger row (idempotently), and only then transmits with bounded backoff. Reconciliation folds the CBP disposition back into the ledger’s terminal state.
import asyncio
import hashlib
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("abi_resubmitter")
class LedgerState(Enum):
PENDING = "PENDING"
SENT = "SENT"
ACKED = "ACKED"
REJECTED = "REJECTED"
@dataclass(frozen=True)
class ResubmitRequest:
entry_number: str
filer_code: str
entry_type: str
corrected_payload: bytes # canonicalized EDI ready to transmit
entered_value: Decimal
def dedup_key(self) -> str:
# Stable identity + content hash. Correcting the payload changes the
# hash, so a genuine correction is a NEW logical filing; re-sending
# the identical correction is the SAME one and dedups.
digest = hashlib.sha256(self.corrected_payload).hexdigest()[:16]
return f"{self.filer_code}:{self.entry_number}:{self.entry_type}:{digest}"
@dataclass
class Disposition:
acked: bool
control_number: str
reject_reason: Optional[str] = None
class DuplicateTransmission(Exception):
"""Raised when the ledger already holds a terminal state for this key."""
class AbiResubmitter:
def __init__(self, pool, transport, max_attempts: int = 5):
self._pool = pool # asyncpg pool
self._transport = transport
self._max_attempts = max_attempts
async def _claim(self, req: ResubmitRequest) -> tuple[str, LedgerState]:
key = req.dedup_key()
token = uuid.uuid4()
# ON CONFLICT DO NOTHING makes the claim idempotent: a second call
# with the same dedup_key does not insert, so we read existing state.
row = await self._pool.fetchrow(
"""
INSERT INTO abi_transmission_ledger
(dedup_key, entry_number, idempotency_token, state)
VALUES ($1, $2, $3, 'PENDING')
ON CONFLICT (dedup_key) DO NOTHING
RETURNING state
""",
key, req.entry_number, token,
)
if row is None:
existing = await self._pool.fetchrow(
"SELECT state FROM abi_transmission_ledger WHERE dedup_key = $1",
key,
)
return key, LedgerState(existing["state"])
return key, LedgerState.PENDING
async def _mark(self, key: str, state: LedgerState,
control_number: Optional[str] = None) -> None:
await self._pool.execute(
"""
UPDATE abi_transmission_ledger
SET state = $2, control_number = COALESCE($3, control_number),
attempt = attempt + 1, updated_at = $4
WHERE dedup_key = $1
""",
key, state.value, control_number, datetime.now(timezone.utc),
)
async def resubmit(self, req: ResubmitRequest) -> Disposition:
key, state = await self._claim(req)
# Terminal states short-circuit — this is the idempotency guarantee.
if state is LedgerState.ACKED:
logger.info("Dedup hit: %s already ACKED; no wire.", key)
raise DuplicateTransmission(key)
if state is LedgerState.SENT:
logger.warning("In-flight %s; reconcile before re-send.", key)
raise DuplicateTransmission(key)
last_error: Optional[Exception] = None
for attempt in range(1, self._max_attempts + 1):
try:
await self._mark(key, LedgerState.SENT)
disp: Disposition = await self._transport.send(req.corrected_payload)
final = LedgerState.ACKED if disp.acked else LedgerState.REJECTED
await self._mark(key, final, disp.control_number)
logger.info(
"Resubmit %s -> %s on attempt %d (ctrl %s)",
key, final.value, attempt, disp.control_number,
)
return disp
except (ConnectionError, TimeoutError) as exc:
last_error = exc
backoff = min(2 ** (attempt - 1), 30) # 1,2,4,8,16,30s cap
logger.warning(
"Transport error on %s attempt %d: %s; backoff %ss",
key, attempt, exc, backoff,
)
await self._mark(key, LedgerState.PENDING)
await asyncio.sleep(backoff)
logger.error("Resubmit %s exhausted %d attempts", key, self._max_attempts)
raise last_error or RuntimeError("resubmit failed with no captured error")
The idempotency guarantee is entirely in _claim plus the terminal-state short-circuit: once a dedup key reaches ACKED, resubmit refuses to touch the wire again and raises DuplicateTransmission. A transport error rolls the row back to PENDING so a retry re-sends the same payload under the same key — never a new filing.
ACKED raises DuplicateTransmission instead of re-filing, while a transport error loops back to PENDING and retries the identical payload under the identical key with capped backoff.Verification steps
- Double-send is a no-op. Call
resubmittwice with the sameResubmitRequestafter the first reachesACKED. The second must raiseDuplicateTransmissionand the transportsendmust be invoked exactly once. Assert the ledger has one row for the key. - Correction changes identity. Mutate
corrected_payload, recomputededup_key, and confirm it differs. A genuine correction must be a new logical filing; an identical re-send must collide on the primary key. - Crash between claim and send. Kill the process after
_claiminsertsPENDINGbut beforeSENT. On restart,resubmitmust re-claim, readPENDING, and proceed — no duplicate row, no stranded entry. - Backoff schedule. Force three consecutive
TimeoutErrors and assert sleeps of 1, 2, 4 seconds (capped at 30), the row returning toPENDINGbetween attempts, and the attempt counter incrementing in the ledger. - Exhaustion is safe. Exceed
max_attemptsand assert the original transport exception propagates, the ledger’s last state isPENDING(not a phantomACKED), and no duplicate was filed. - Disposition reconciliation. For an
ACKEDresult, join the returnedcontrol_numberback to the ledger row and to the outbound interchange; for aREJECTEDresult, confirm it re-enters the 824 decode path rather than blindly retrying. - Unique-constraint enforcement. Attempt to insert two rows with the same
dedup_keydirectly in SQL. The database must reject the second — application logic alone is not sufficient proof of idempotency.
Edge cases & gotchas
- Idempotency token vs. dedup key. They are not the same thing. The dedup key is stable across attempts of one logical filing; the idempotency token is fresh per attempt for tracing. Confusing them — rotating the dedup key per attempt — reintroduces duplicate filings, the exact bug this page prevents.
- In-flight is not terminal. A row stuck in
SENTmeans you transmitted and never recorded a disposition. Do not blindly resubmit it; reconcile first by decoding any pending 824 or querying entry status, or you risk a genuine duplicate at CBP. A reaper that agesSENTrows into a manual-reconcile queue is safer than an auto-retry. - Backoff must not retry hard rejects. Exponential backoff belongs to transport failures (connection dropped, timeout) — never to an application reject. A hard 824 error means the content is wrong; retrying the identical payload just earns the identical rejection. Correct the payload (new dedup key) and only then resubmit, following the same retry-logic separation used across the ingestion pipeline.
- Content hash must be canonical. If
corrected_payloadis re-serialized with different whitespace, segment order, or delimiter choices between attempts, the hash changes and dedup silently fails. Canonicalize the EDI before hashing — same bytes in, same key out. - Timezone-naive backoff. Scheduling the next attempt off a naive local clock drifts across DST boundaries and can fire early or late relative to CBP’s transmission windows. Use
datetime.now(timezone.utc)everywhere, as the ledger’sTIMESTAMPTZcolumns already assume. - The bond is real money. A duplicate entry can obligate a second bond amount and duty deposit. Idempotency here is not a nicety; it is the control that keeps a retry from becoming a financial liability. Treat any duplicate that reaches CBP as a reportable incident, not a silent recovery.
Related
- Decoding ACE X12 824 application advice errors
- Error Handling & Retry Logic
- Designing exponential backoff for failed parsing jobs
- ACE and ABI Rejection Handling
Up: ACE and ABI Rejection Handling
Authoritative references: CBP ACE ABI CATAIR (Customs and Trade Automated Interface Requirements), entry summary transmission and disposition messages; ASC X12 824 Application Advice; 19 CFR 142 (entry) and 19 CFR 163 (recordkeeping).