Syncing Instrument Metadata to a LIMS over REST
Pushing enriched acquisition metadata from a lab host into a Laboratory Information Management System over a REST/HTTP API is where reproducible records go to die quietly: a lost 200 response duplicates a sample, a concurrent edit clobbers an ETag, and a LIMS outage silently drops a shift’s worth of runs. This guide makes that hop reliable with a client-generated record UUID, an idempotency key, and a local durable outbox, extending the enrichment stage described in Metadata Injection Workflows within the wider Data Capture, Validation & Metadata Sync architecture.
Scope: At-Least-Once REST Delivery from an Acquisition Host
The problem starts once a metadata record has already been validated and serialized — the deterministic payload produced by the injector — and must reach a LIMS reachable only over HTTP. Two properties of that transport dominate the design. First, HTTP over a lab network is unreliable in a specific way: the request can succeed on the server while the response is lost to a dropped connection, a proxy timeout, or the acquisition host being power-cycled mid-flight. The client cannot distinguish “the LIMS never saw it” from “the LIMS committed it but I never heard back,” so any retry must assume the record may already exist. Second, the acquisition loop must never block on the LIMS being up; a plate reader running overnight cannot pause because an appliance is mid-reboot.
Both constraints point to the same shape: an at-least-once pipeline built on a local durable outbox and made safe by idempotent writes. The acquisition thread does one cheap thing — append the validated record to a SQLite table and return. A separate draining worker owns every network concern: authentication, retry, status classification, and concurrency control. This is the durable-queue fallback that the parent guide names in the abstract; here it is the primary path, not a degraded one. The scope explicitly excludes payload construction (covered upstream), regulated provenance fields (see Injecting ALCOA+ Provenance Metadata for 21 CFR Part 11), and the clock discipline that stamps each record (see Deterministic UTC Timestamping for Multi-Instrument Runs). We assume Python 3.10+, a LIMS REST API that honours an Idempotency-Key header and conditional If-Match/ETag requests, and bearer-token auth.
Idempotency Keys, Status Classification, and the Outbox Drain Bound
Idempotency is enforced with two client-generated identifiers that travel with the record from the moment it is enqueued. The record UUID is the resource identity: the write is a PUT /records/{uuid}, so re-sending the same UUID targets the same server row rather than creating a new one — a PUT to a client-chosen key is naturally idempotent in a way a bare POST is not. The idempotency key is a per-attempt-safe token in the Idempotency-Key header that lets the LIMS collapse duplicate deliveries of the same logical write: if the server already committed that key, it replays the stored 200/201 instead of applying the body twice. The UUID gives you a stable address; the idempotency key gives you exactly-once effect on top of at-least-once delivery.
Whether a failed attempt is retried or abandoned is decided entirely by the HTTP status class, and getting that mapping wrong is how outboxes either grow forever or discard good data. The classification is the HTTP dialect of Error Code Categorization: 2xx is success and captures the returned ETag; 429 and 5xx are transient (rate-limit or server-side) and must be retried with a bounded, growing delay; a 409 Conflict signals an ETag mismatch and needs reconciliation before a retry can succeed; and any other 4xx — 400 malformed, 422 schema rejection, 403 scope — is a permanent client fault that must be quarantined to a dead-letter state and alerted, never retried, because the same bytes will fail identically forever. The one exception is 401, handled as a token-refresh-then-retry rather than a dead-letter.
The retry delay itself is the same bounded exponential curve derived for serial timeout handling — delay = min(base · 2^attempts, max_delay) — so it is not re-derived here; the outbox simply stores a next_attempt_at timestamp per row and the worker respects it. Optimistic concurrency uses the ETag as a version token: every read of the LIMS record returns an ETag, and the next write sends it back as If-Match. If another writer changed the record in between, the server rejects the write with 409 and the worker must refetch before retrying. This is what keeps two hosts editing the same sample record from silently overwriting each other. Finally, every payload carries an envelope version (X-Envelope-Version) so a LIMS schema change produces an explicit 422 you can quarantine and migrate, rather than a coerced, subtly-wrong record.
What bounds the outbox is a queueing argument. Let acquisition enqueue records at rate λ (records/s) and let the drain worker sustain throughput μ = c / t_req, where c is worker concurrency and t_req the mean successful round-trip. The backlog B(t) obeys a fill-and-drain balance, and during a LIMS outage of duration D it grows linearly, then drains only if μ > λ:
The design rule falls straight out of the denominator: provision μ with enough headroom over peak λ that μ − λ clears the worst tolerable outage backlog λD inside your recovery-time objective. A worker that can only just keep up (μ ≈ λ) never recovers from an outage — t_drain diverges — and the SQLite file grows without bound until the disk fills.
The outbox drain and status-classification path: 2xx commits and stores the ETag, 429/5xx reschedules with backoff, 409 reconciles the ETag before retrying, and non-retryable 4xx is quarantined.
A Durable Outbox and Idempotent LIMS Client
The implementation splits cleanly: LimsOutbox owns the SQLite durability and the retry bookkeeping, LimsClient.upsert_record() owns one idempotent HTTP attempt and its classification, and a small drain loop ties them together. It uses requests, but every call sits behind a method boundary, so a stdlib http.client/urllib transport drops in unchanged. The digest carried in each row is the transport-integrity tag from Checksum & CRC Validation, so the worker can detect a payload that was corrupted at rest before it ever hits the wire.
from __future__ import annotations
import sqlite3
import time
import uuid
from dataclasses import dataclass
from enum import Enum
import requests
_SCHEMA = """
CREATE TABLE IF NOT EXISTS outbox (
record_uuid TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL,
resource_path TEXT NOT NULL,
payload BLOB NOT NULL,
etag TEXT,
state TEXT NOT NULL DEFAULT 'PENDING', -- PENDING | SENT | DEAD
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL NOT NULL DEFAULT 0,
last_error TEXT
);
"""
class Outcome(str, Enum):
SUCCESS = "success" # 2xx: committed, capture new ETag
RETRY = "retry" # 429/5xx/401: transient, reschedule with backoff
CONFLICT = "conflict" # 409: ETag mismatch, refetch then retry
QUARANTINE = "quarantine" # other 4xx: permanent client fault, dead-letter
@dataclass(frozen=True)
class BackoffPolicy:
base_delay: float = 0.5 # seconds; doubled per attempt (bounded)
max_delay: float = 60.0 # cap on any single wait
max_attempts: int = 8 # dead-letter after this many transient failures
def delay_for(self, attempts: int) -> float:
"""Bounded exponential delay; identical to the serial-backoff curve."""
return min(self.base_delay * (2 ** attempts), self.max_delay)
class LimsOutbox:
"""Durable at-least-once queue of metadata records awaiting LIMS delivery."""
def __init__(self, db_path: str) -> None:
self._conn = sqlite3.connect(db_path, isolation_level=None)
self._conn.execute("PRAGMA journal_mode=WAL;") # crash-safe appends
self._conn.executescript(_SCHEMA)
def enqueue(self, resource_path: str, payload: bytes) -> str:
"""Append a validated record; returns its client-generated UUID."""
record_uuid = str(uuid.uuid4())
self._conn.execute(
"INSERT INTO outbox(record_uuid, idempotency_key, resource_path, payload)"
" VALUES (?, ?, ?, ?)",
(record_uuid, str(uuid.uuid4()), resource_path, payload),
)
return record_uuid
def claim_due(self, now: float, limit: int = 32) -> list[sqlite3.Row]:
"""Rows that are PENDING and whose backoff window has elapsed."""
self._conn.row_factory = sqlite3.Row
return self._conn.execute(
"SELECT * FROM outbox WHERE state='PENDING' AND next_attempt_at<=?"
" ORDER BY next_attempt_at LIMIT ?",
(now, limit),
).fetchall()
def mark_sent(self, record_uuid: str, etag: str | None) -> None:
self._conn.execute(
"UPDATE outbox SET state='SENT', etag=?, last_error=NULL"
" WHERE record_uuid=?",
(etag, record_uuid),
)
def reschedule(self, record_uuid: str, attempts: int,
next_at: float, error: str, etag: str | None = None) -> None:
self._conn.execute(
"UPDATE outbox SET attempts=?, next_attempt_at=?, last_error=?,"
" etag=COALESCE(?, etag) WHERE record_uuid=?",
(attempts, next_at, error[:500], etag, record_uuid),
)
def mark_dead(self, record_uuid: str, error: str) -> None:
self._conn.execute(
"UPDATE outbox SET state='DEAD', last_error=? WHERE record_uuid=?",
(error[:500], record_uuid),
)
def backlog(self) -> int:
return self._conn.execute(
"SELECT COUNT(*) FROM outbox WHERE state='PENDING'"
).fetchone()[0]
class LimsClient:
"""Issues one idempotent conditional upsert and classifies the response."""
def __init__(self, base_url: str, token_provider, timeout: float = 10.0) -> None:
self._base = base_url.rstrip("/")
self._token_provider = token_provider # callable -> fresh bearer token
self._timeout = timeout
self._session = requests.Session()
def upsert_record(self, row: sqlite3.Row) -> tuple[Outcome, str | None, str]:
"""PUT the record idempotently; return (outcome, new_etag, detail)."""
headers = {
"Authorization": f"Bearer {self._token_provider()}",
"Content-Type": "application/json",
"Idempotency-Key": row["idempotency_key"],
"X-Envelope-Version": "2",
}
if row["etag"]: # optimistic concurrency guard
headers["If-Match"] = row["etag"]
url = f"{self._base}{row['resource_path']}/{row['record_uuid']}"
try:
resp = self._session.put(
url, data=row["payload"], headers=headers, timeout=self._timeout,
)
except requests.RequestException as exc:
# No response ever arrived: safe to retry, PUT+key make it idempotent.
return Outcome.RETRY, None, f"transport: {exc}"
if resp.status_code < 300:
return Outcome.SUCCESS, resp.headers.get("ETag"), str(resp.status_code)
if resp.status_code == 409:
return Outcome.CONFLICT, resp.headers.get("ETag"), "etag mismatch"
if resp.status_code in (401, 429) or resp.status_code >= 500:
return Outcome.RETRY, None, f"transient {resp.status_code}"
return Outcome.QUARANTINE, None, f"permanent {resp.status_code}: {resp.text[:200]}"
def drain_once(outbox: LimsOutbox, client: LimsClient,
policy: BackoffPolicy = BackoffPolicy()) -> None:
"""Process every due row exactly once through classification + backoff."""
now = time.time()
for row in outbox.claim_due(now):
outcome, etag, detail = client.upsert_record(row)
if outcome is Outcome.SUCCESS:
outbox.mark_sent(row["record_uuid"], etag)
continue
attempts = row["attempts"] + 1
if outcome is Outcome.QUARANTINE or attempts >= policy.max_attempts:
outbox.mark_dead(row["record_uuid"], detail)
continue
# CONFLICT stores the refetched ETag so the retry sends a fresh If-Match.
next_at = now + policy.delay_for(attempts)
outbox.reschedule(row["record_uuid"], attempts, next_at, detail, etag)
Two decisions carry the correctness. The upsert_record transport-exception branch returns RETRY, not failure, precisely because a dropped response is the lost-200 case — retrying is safe only because the PUT+Idempotency-Key combination guarantees the server collapses the duplicate. And a CONFLICT reschedule stores the ETag the server returned on the 409, so the next attempt sends the current version in If-Match and stops fighting a stale one.
Validating Idempotent Replay Against the Outbox Table
Verify two things: that a lost response never produces a duplicate, and that the outbox reaches a terminal state for every record. The first is a server-side replay test — send the same UUID and idempotency key twice and assert the LIMS created exactly one row. The second is a table invariant: after the worker drains, no row may sit in PENDING unless its next_attempt_at is in the future.
def test_idempotent_replay_creates_one_record(outbox, client, lims_probe):
uuid_ = outbox.enqueue("/records", b'{"sample_id":"S-1","value":42}')
drain_once(outbox, client) # first delivery: server commits
row = _row(outbox, uuid_)
assert row["state"] == "SENT"
# Simulate the lost-200: re-arm the row and drain again with the SAME key.
outbox._conn.execute(
"UPDATE outbox SET state='PENDING', next_attempt_at=0 WHERE record_uuid=?",
(uuid_,))
drain_once(outbox, client)
assert lims_probe.count("/records", uuid_) == 1 # replayed, not duplicated
assert outbox.backlog() == 0 # no rows stuck PENDING
On a live LIMS, confirm the same property by inspecting the outbox directly: SELECT state, attempts, last_error FROM outbox after a batch should show SENT rows with attempts reflecting real transient retries, DEAD rows carrying a permanent 4xx in last_error, and no PENDING row older than one backoff window. A row’s attempts climbing to max_attempts with a 5xx in last_error is the signal that the LIMS is genuinely down, not that the record is bad — exactly the distinction the drain worker preserves by dead-lettering 4xx but retrying 5xx.
Failure Modes Specific to REST Metadata Sync
Duplicate submission after a lost 200. The LIMS commits the write, the response is lost to a proxy timeout, the worker retries, and without idempotency you now have two sample records. The signature is duplicate rows in the LIMS sharing every field but the primary key. The fix is structural, not defensive: the client-generated UUID makes the retry target the same resource, and the Idempotency-Key makes the server replay its stored result. If duplicates still appear, the LIMS is not honouring the key — verify with the replay test above before trusting the endpoint.
ETag mismatch under concurrent writers. Two acquisition hosts (or a host and a manual LIMS edit) touch the same record; the second write arrives with a stale If-Match and the LIMS returns 409. The tell is a row whose attempts climbs while last_error stays etag mismatch. Retrying the same body with the same stale ETag loops forever — that is why the worker stores the server’s returned ETag on the 409 and refetches before retrying. If a record genuinely has two authoritative writers, the 409 is telling you to merge at the application layer, not to keep overwriting.
Unbounded outbox growth during a LIMS outage. While the LIMS is unreachable, every attempt classifies as RETRY, PENDING count rises, and the SQLite file grows at λ records per second. If μ has no headroom over λ, the backlog λD never drains after recovery and the disk fills. Watch outbox.backlog() as a first-class metric and wire it to Threshold Tuning & Alerting; a backlog rising past what μ − λ can clear inside your recovery window is the early warning, long before the disk does.
Bearer token expiry mid-batch. A long drain outlives the token TTL, and every remaining record starts returning 401. Because 401 is classified as RETRY, not QUARANTINE, the records are not lost — but a token provider that returns the same expired token on every call turns the whole batch into a slow march to the max_attempts dead-letter. The token_provider callable must refresh on expiry (cache with a TTL and re-mint), so the next attempt in the same drain carries a live token. Diagnose by correlating a wave of 401s in last_error with the token lifetime; a clustered onset at a round interval is expiry, not authorization failure.
Related
- Metadata Injection Workflows — the enrichment stage that produces the validated records this worker delivers.
- Injecting ALCOA+ Provenance Metadata for 21 CFR Part 11 — the regulated provenance fields carried inside each synced record.
- Deterministic UTC Timestamping for Multi-Instrument Runs — the clock discipline behind the acquisition timestamp each record stamps.
- Error Code Categorization — the retry-vs-quarantine boundary applied here to HTTP status classes.
- Checksum & CRC Validation — the integrity tag that guards each outbox payload before transmission.
← Back to Metadata Injection Workflows
References
- MDN HTTP conditional requests —
ETag,If-Match, and409semantics for optimistic concurrency. - Requests API documentation —
Session, timeouts, and exception handling used by the client. - Python
sqlite3documentation — WAL journaling andRowaccess used by the durable outbox.