Syncing packing lists to shipment records via API

The precise failure this page addresses: a normalized packing list has to become one authoritative shipment record, exactly once, over a REST gateway that will occasionally time out, occasionally return a 5xx, and occasionally reject the body outright — and none of those conditions may produce a duplicate entry, a lost line item, or a malformed payload reaching an ACE/ABI submission queue. In a customs brokerage pipeline the latency between physical receipt and digital record creation is measurable compliance risk, and a duplicated or dropped line_items array during a retry storm is a 19 CFR § 163 recordkeeping defect, not merely a bug. The sync layer is where deterministic normalization output meets a non-deterministic network, so it must be built around three guarantees: idempotency, bounded retry, and safe quarantine.

This page assumes the record is already clean. Upstream, Packing List Data Normalization has already resolved mixed units of measure, deduplicated line items, and enforced HTSUS formatting, so every payload that reaches the sync layer conforms to the shipment-record API contract. The sync layer’s only job is transport — it does not re-parse, re-classify, or re-normalize. If a field is wrong here, the fix belongs upstream, not in a retry.

Prerequisites

Pin the runtime and dependencies so behavior is reproducible across broker environments:

  • Python 3.10+ — the implementation uses match-free structural typing but relies on 3.10 X | None union syntax in type hints and modern dataclasses.
  • aiohttp>=3.9 for the async client session; asyncio from the standard library for the event loop and asyncio.sleep backoff.
  • hashlib (stdlib) for SHA-256 idempotency keys — no third-party crypto dependency.
  • A gateway endpoint (SHIPMENT_SYNC_URL) and a bearer/API credential supplied via environment variable, never hard-coded, to keep CUI out of source control.
  • The gateway must honor an Idempotency-Key header. If it does not, the reconciliation guarantee described below cannot hold and you must implement a pre-POST existence check against the shipment ledger instead.
  • Upstream normalization completed — see the section above — so the line_items array already carries validated hts_code, country_of_origin, and uom fields.

Implementation

The pattern combines a SHA-256 idempotency key derived from the canonical payload, a per-endpoint circuit breaker, exponential backoff with jitter, and structured JSON-line logging so each record slots into CloudWatch, Loki, or Datadog ingestion. Terminal validation errors (409, 422) short-circuit the retry loop; only transport faults (5xx, timeouts) consume the retry budget and increment the breaker.

Circuit-breaker state machine and HTTP status-code classification for the sync loop The left panel is the response classifier: a 200 records success and returns; a 409 or 422 is terminal and raises straight to quarantine without consuming a retry; a 5xx or timeout is retryable, records a breaker failure, and backs off with jitter capped at 30 seconds before looping. The right panel is the circuit breaker's three states. CLOSED admits every request; on reaching the failure_threshold of five consecutive failures it transitions to OPEN, which blocks all requests. After recovery_timeout of sixty seconds OPEN moves to HALF_OPEN, admitting a single probe: a success returns the breaker to CLOSED and resets the failure count, while a failure sends it back to OPEN. RESPONSE CLASSIFIER · RETRY LOOP POST attempt Idempotency-Key status? inspect body 200 → return record_success() 409 · 422 terminal → raise 5xx · timeout record_failure() backoff + jitter ≤ 30s Quarantine compliance hold 200 4xx 5xx retry (< max) max CIRCUIT BREAKER · STATE MACHINE CLOSED allow_request → True OPEN block all requests HALF_OPEN admit one probe failures ≥ 5 (threshold) after 60s recovery_timeout probe ok reset count probe fails success
import asyncio
import aiohttp
import logging
import time
import hashlib
import json
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, field
from enum import Enum
from logging.handlers import RotatingFileHandler

# Configure structured production logging — emit each record as a JSON line
# so it slots cleanly into CloudWatch / Loki / Datadog ingestion.
class _JsonLineFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
        }
        # Merge structured fields supplied via `extra={...}`.
        for key, value in record.__dict__.items():
            if key in ("args", "msg", "levelname", "levelno", "pathname", "filename",
                       "module", "exc_info", "exc_text", "stack_info", "lineno",
                       "funcName", "created", "msecs", "relativeCreated", "thread",
                       "threadName", "processName", "process", "name"):
                continue
            payload[key] = value
        return json.dumps(payload, default=str)

logger = logging.getLogger("customs_sync_pipeline")
logger.setLevel(logging.INFO)
handler = RotatingFileHandler("pipeline_sync.log", maxBytes=5 * 1024 * 1024, backupCount=3)
handler.setFormatter(_JsonLineFormatter())
logger.addHandler(handler)

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    recovery_timeout: float = 60.0
    failure_count: int = 0
    last_failure_time: float = 0.0
    state: CircuitState = field(default=CircuitState.CLOSED)

    def record_failure(self) -> None:
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN
            logger.warning("Circuit breaker OPEN due to consecutive failures.")

    def record_success(self) -> None:
        self.failure_count = 0
        self.state = CircuitState.CLOSED

    def allow_request(self) -> bool:
        if self.state == CircuitState.CLOSED:
            return True
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time >= self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
                return True
            return False
        return True  # HALF_OPEN allows one probe

@dataclass
class ShipmentSyncPayload:
    shipment_id: str
    line_items: List[Dict[str, Any]]
    idempotency_key: str
    metadata: Dict[str, str] = field(default_factory=dict)

def generate_idempotency_key(payload: Dict[str, Any]) -> str:
    # Canonicalize with sort_keys so a retried, semantically identical payload
    # hashes to the SAME key and reconciles instead of duplicating a line.
    raw = json.dumps(payload, sort_keys=True).encode("utf-8")
    return f"pl_sync_{hashlib.sha256(raw).hexdigest()[:16]}"

async def sync_packing_list_to_shipment(
    session: aiohttp.ClientSession,
    api_endpoint: str,
    payload: ShipmentSyncPayload,
    breaker: CircuitBreaker,
    max_retries: int = 4
) -> Dict[str, Any]:
    if not breaker.allow_request():
        logger.critical("Emergency pause active. Request blocked by circuit breaker.")
        raise RuntimeError("Circuit breaker OPEN")

    headers = {
        "Content-Type": "application/json",
        "Idempotency-Key": payload.idempotency_key,
        "X-Trade-Compliance-Version": "2.1"
    }

    body = {
        "shipment_id": payload.shipment_id,
        "line_items": payload.line_items,
        "metadata": payload.metadata,
    }

    for attempt in range(max_retries):
        try:
            async with session.post(
                api_endpoint,
                json=body,
                headers=headers,
                timeout=aiohttp.ClientTimeout(total=15)
            ) as response:
                if response.status == 200:
                    breaker.record_success()
                    logger.info("Sync successful", extra={"shipment_id": payload.shipment_id, "status": 200})
                    return await response.json()
                elif response.status in (409, 422):
                    # Terminal: schema/content defect or duplicate key. Do NOT retry —
                    # raise so the caller can quarantine for broker correction.
                    logger.error("Schema or idempotency conflict", extra={"status": response.status})
                    raise aiohttp.ClientError(f"Validation failed: {await response.text()}")
                elif response.status >= 500:
                    raise aiohttp.ClientResponseError(
                        request_info=response.request_info,
                        history=response.history,
                        status=response.status,
                        message="Gateway error"
                    )
        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
            breaker.record_failure()
            delay = min(2 ** attempt + (hash(str(time.time())) % 1000 / 1000), 30)
            logger.warning(f"Retry {attempt + 1}/{max_retries} after {delay:.2f}s", extra={"error": str(e)})
            await asyncio.sleep(delay)
            continue

    logger.critical("Max retries exhausted. Payload quarantined for manual review.")
    raise RuntimeError("Sync failed after maximum retries")

The idempotency key is the load-bearing element. Because generate_idempotency_key canonicalizes with sort_keys=True before hashing, a byte-identical retry produces the same Idempotency-Key, and a compliant gateway returns the original 200 body rather than creating a second shipment line. A genuine amendment — a corrected weight, an added carton — changes the payload, changes the hash, and is therefore treated as a distinct write the shipment ledger can version. This is what lets the retry loop run without fear of duplication.

Verification steps

Confirm correct behavior before trusting the pipeline against production ACE/ABI volume:

  1. Idempotency-key stability. Call generate_idempotency_key twice on the same dict and assert the result is identical. Reorder the dict keys and assert the result is still identical — sort_keys=True must neutralize insertion order, or retried payloads will fork into duplicates.
  2. Duplicate-POST replay. Post the same payload twice against a staging gateway. The second call must return the original record (a 200 with the same shipment_id), and a SELECT count(*) against the shipment ledger for that key must return exactly 1.
  3. Terminal-vs-retryable classification. Stub the gateway to return 422, then 409, then 503. Assert 422 and 409 raise on the first attempt with attempt == 0 (no retry consumed), while 503 backs off and increments breaker.failure_count.
  4. Circuit-breaker transitions. Drive five consecutive failures and assert breaker.state == CircuitState.OPEN and allow_request() returns False. Advance a monotonic clock past recovery_timeout and assert exactly one HALF_OPEN probe is admitted, then that a record_success() returns the breaker to CLOSED.
  5. Backoff bound. Assert the computed delay never exceeds the 30-second cap even at the final attempt, so a slow gateway cannot stall a worker indefinitely.
  6. Quarantine count. After max_retries is exhausted, assert the payload is written to the compliance-hold quarantine with its raw request and sanitized response captured, and that the emergency-pause rolling-window counter is incremented for the 15-minute rejection-rate check.

Edge cases & gotchas

  • The jitter term is not cryptographic. hash(str(time.time())) is a cheap desynchronizer, not a secure random source; it is fine for spreading retries but must never be reused as an idempotency or nonce value. If two workers hit time.time() within the same microsecond they can still collide on delay — acceptable for backoff, unacceptable for keys.
  • sort_keys does not recurse into list order. Canonicalization sorts dict keys but preserves list order. If upstream Packing List Data Normalization emits line_items in a non-deterministic order, two runs of the same shipment will hash differently and defeat idempotency. Sort line_items by a stable key (e.g. SKU then line number) before generating the key.
  • A gateway that ignores Idempotency-Key silently breaks the whole guarantee. There is no client-side error for this — you get duplicate rows and clean 200s. Confirm header support with the replay test above before relying on it; if unsupported, gate the POST behind a ledger existence check.
  • 422 masquerading as transient. Some gateways return 502/503 from an upstream proxy when the real error is a 422 from the application. Blind retry then wastes the budget and delays quarantine. Inspect the response body, not just the status line, and treat a validation error envelope as terminal regardless of the HTTP code.
  • Character-encoding corruption leaks through as a 422. A packing list processed through Multi-language Invoice Parsing without NFC normalization can carry combining characters that serialize to bytes the gateway rejects. The sync layer will faithfully retry-then-quarantine, but the root cause is upstream encoding, not transport — check the quarantined payload’s raw bytes before re-queuing.
  • Circuit-breaker state is per-process. The breaker in this example lives in memory, so a fleet of workers each maintains its own view of gateway health. Under a real outage they will trip independently and at different times; for coordinated back-off, externalize the state to a shared store (Redis) keyed by endpoint.
  • raise_for_status is deliberately not used. The loop inspects response.status by hand precisely so it can classify 409/422 as terminal and 5xx as retryable; enabling aiohttp’s automatic raise_for_status would collapse that distinction and send validation defects into the retry path.

When these guards are in place, the sync layer becomes a deterministic bridge: normalized packing-list data lands in the master shipment record exactly once, transient gateway faults self-heal within the retry budget, systemic defects surface as a compliance hold instead of a flood of rejected entries, and every payload retains an audit-ready trail consistent with CBP ACE message-set requirements and 19 CFR § 163 recordkeeping obligations.

Up one level: Packing List Data Normalization

Authoritative references: Automated Commercial Environment (ACE) · 19 CFR § 163 recordkeeping · Python asyncio task documentation