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, and Decimal for any monetary field that rides along in the payload. Never use float for 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 logging with %(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.

Idempotent resubmission state machine for a rejected ABI entry A resubmission begins by claiming a ledger row keyed on a dedup key derived from filer code, entry number, entry type, and a content hash of the corrected payload. The claim uses an insert with on-conflict-do-nothing, so a repeated claim reads the existing state instead of inserting. If the existing state is ACKED, the request short-circuits as a duplicate and never touches the wire. If it is SENT, the request is in-flight and must be reconciled first. Otherwise the row is PENDING: it moves to SENT, transmits, and on a clean disposition moves to the terminal ACKED state, or to REJECTED if CBP refused the content again. A transport-level connection or timeout error returns the row to PENDING and retries with exponential backoff capped at thirty seconds, up to a maximum attempt count, after which the request fails without duplicating the filing. Reconciliation joins the returned control number back to the ledger row. claim() dedup_key insert on conflict → read existing state? ACKED / SENT DuplicateTransmission no wire · short-circuit PENDING SENT transmit payload ACKED terminal · reconcile REJECTED decode 824 again transport error → back to PENDING backoff 1,2,4,8,16 · cap 30s · max attempts same key, same payload
The dedup key gates the wire: a key already in 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

  1. Double-send is a no-op. Call resubmit twice with the same ResubmitRequest after the first reaches ACKED. The second must raise DuplicateTransmission and the transport send must be invoked exactly once. Assert the ledger has one row for the key.
  2. Correction changes identity. Mutate corrected_payload, recompute dedup_key, and confirm it differs. A genuine correction must be a new logical filing; an identical re-send must collide on the primary key.
  3. Crash between claim and send. Kill the process after _claim inserts PENDING but before SENT. On restart, resubmit must re-claim, read PENDING, and proceed — no duplicate row, no stranded entry.
  4. Backoff schedule. Force three consecutive TimeoutErrors and assert sleeps of 1, 2, 4 seconds (capped at 30), the row returning to PENDING between attempts, and the attempt counter incrementing in the ledger.
  5. Exhaustion is safe. Exceed max_attempts and assert the original transport exception propagates, the ledger’s last state is PENDING (not a phantom ACKED), and no duplicate was filed.
  6. Disposition reconciliation. For an ACKED result, join the returned control_number back to the ledger row and to the outbound interchange; for a REJECTED result, confirm it re-enters the 824 decode path rather than blindly retrying.
  7. Unique-constraint enforcement. Attempt to insert two rows with the same dedup_key directly 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 SENT means 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 ages SENT rows 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_payload is 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’s TIMESTAMPTZ columns 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.

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).