Syncing Smart-Meter AMI Feeds via REST APIs

A REST collector that pulls interval reads from an AMI head-end has exactly one job that matters to billing: never skip a page and never re-count one. This page is the concrete transport implementation behind Step 1 of AMI/AMR Feed Synchronization Protocols, inside Meter Data Ingestion & Validation Pipelines. The failure mode it resolves is subtle: a paginated pull that commits its cursor before the reads are safely handed off, so a mid-batch token expiry, a 429, or a crash silently drops a window of consumption — or a naive retry replays a page and inflates it. The fix is a collector where the durable cursor advances only after the fetched records clear validation and reach the billing boundary, and where every retry is a no-op by construction.

Prerequisites

Imports and the data contract this collector assumes — nothing else.

import asyncio
import hashlib
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal

import httpx  # httpx>=0.27, async client with per-request timeouts
  • Python 3.11+ for timezone-aware datetime and modern asyncio; never pytz, never naive timestamps.
  • httpx.AsyncClient as the transport so the collector is non-blocking and integrates with async batch processing for high-volume reads.
  • decimal.Decimal for every cumulative_kwh register value — binary float drift compounds across millions of intervals into penny-level ledger discrepancies.
  • A persisted cursor store. The head-end exposes cursor-based pagination (next_cursor); the last acknowledged cursor must survive a process restart (a row in the staging DB, not in-memory state).
  • An OAuth 2.0 client-credentials grant whose access token expires and must be refreshed mid-pull. The head-end returns cumulative registers, not pre-computed deltas.

Annotated Implementation

The collector below is the whole transport layer. It refreshes the token before expiry, walks pages until the cursor is exhausted or rate limits force a pause, normalizes each raw record into the canonical envelope the rest of the pipeline consumes, and — the load-bearing detail — persists the next cursor only after the page has been handed off downstream.

The AMI REST collector loop: the cursor commits last A pagination loop with six ordered steps. Step one loads the durable cursor to resume from the last acknowledged page. Step two refreshes the OAuth token if it is near expiry, using a sixty-second skew buffer. Step three issues the GET request for a page of interval reads. Step four normalizes each raw record into a SyncEnvelope with a Decimal register and a content-derived source_hash. Step five hands the page off to validation and an idempotent upsert. Step six commits the next cursor durably — the cursor moves last. A decision then checks whether next_cursor is None: if so the feed is drained and the total is returned, otherwise the loop repeats for the next page. Two failure branches are annotated: on a 429 or 503 the collector waits for Retry-After and re-requests the same cursor, so no page is acknowledged and no data gap opens; if the handoff raises, the cursor stays pinned to the last successful page and the crash leaves the old cursor intact, so the re-pull on the next run is a harmless no-op thanks to the source_hash. loop until cursor exhausted more pages 1 · Load durable cursor resume from last acknowledged page 2 · Refresh token if near expiry 60s skew buffer — never a mid-page 401 3 · GET /v1/intervals?cursor fixed pageSize, cursor from the store 4 · Normalize → SyncEnvelope Decimal register + source_hash 5 · handoff(records) validate + idempotent upsert (must succeed) 6 · commit(next_cursor) durable acknowledge — CURSOR MOVES LAST next_cursor is None? None feed drained — return total On 429 / 503 wait Retry-After, then re-request the SAME cursor — no data gap If handoff raises cursor stays pinned; the crash leaves the old cursor intact, so next-run re-pull is a no-op
@dataclass(slots=True, frozen=True)
class SyncEnvelope:
    """Transport-agnostic record handed to validation. source_hash is the
    idempotency basis: two deliveries of the same read collapse to one identity."""
    meter_id: str
    local_ts: str            # meter wall-clock, ISO 8601, still naive
    cumulative_kwh: Decimal
    status_code: int
    source_hash: str


def _source_hash(meter_id: str, local_ts: str, cumulative_kwh: Decimal) -> str:
    # Content address of the physical reading only — never transport metadata —
    # so a page replay produces identical hashes and upserts become no-ops.
    basis = f"{meter_id}|{local_ts}|{cumulative_kwh}"
    return hashlib.sha3_256(basis.encode("utf-8")).hexdigest()


class AMIRestCollector:
    def __init__(self, base_url: str, client: httpx.AsyncClient, cursor_store):
        self._base_url = base_url.rstrip("/")
        self._client = client
        self._cursor_store = cursor_store          # persistent: get()/commit()
        self._token: str | None = None
        self._token_expiry = datetime.min.replace(tzinfo=timezone.utc)

    async def _auth_header(self) -> dict[str, str]:
        # Refresh 60s early: a token that expires mid-page turns a clean pull
        # into a 401 retry storm. Clock skew of a minute is cheap insurance.
        now = datetime.now(timezone.utc)
        if self._token is None or now >= self._token_expiry - timedelta(seconds=60):
            resp = await self._client.post(
                f"{self._base_url}/oauth/token",
                data={"grant_type": "client_credentials"},
            )
            resp.raise_for_status()
            body = resp.json()
            self._token = body["access_token"]
            self._token_expiry = now + timedelta(seconds=int(body["expires_in"]))
        return {"Authorization": f"Bearer {self._token}"}

    @staticmethod
    def _normalize(raw: dict) -> SyncEnvelope:
        # Coerce the register to Decimal at the boundary, before any arithmetic.
        meter_id = raw["meterId"]
        local_ts = raw["readTimeLocal"]
        cumulative = Decimal(str(raw["cumulativeKwh"]))
        return SyncEnvelope(
            meter_id=meter_id,
            local_ts=local_ts,
            cumulative_kwh=cumulative,
            status_code=int(raw.get("statusCode", 0)),
            source_hash=_source_hash(meter_id, local_ts, cumulative),
        )

    async def sync(self, handoff) -> int:
        """Pull every available page; return the count of records handed off.
        `handoff(records)` must durably persist the batch (validate + upsert)
        and RAISE on failure, so a bad handoff never advances the cursor."""
        cursor = await self._cursor_store.get()      # resumes where we stopped
        total = 0

        while True:
            headers = await self._auth_header()
            params = {"cursor": cursor, "pageSize": 1000} if cursor else {"pageSize": 1000}
            resp = await self._client.get(
                f"{self._base_url}/v1/intervals", headers=headers, params=params
            )

            # 429 / 503: obey Retry-After, then re-request the SAME cursor.
            # We have not advanced, so this is a safe wait, not a data gap.
            if resp.status_code in (429, 503):
                await asyncio.sleep(self._retry_after(resp))
                continue
            resp.raise_for_status()

            body = resp.json()
            records = [self._normalize(r) for r in body["data"]]

            # Hand off BEFORE committing the cursor. If handoff raises, we crash
            # with the old cursor intact and simply re-pull this page next run —
            # the source_hash makes that re-pull idempotent downstream.
            if records:
                await handoff(records)
                total += len(records)

            next_cursor = body.get("nextCursor")
            await self._cursor_store.commit(next_cursor)   # durable acknowledge
            cursor = next_cursor

            # Proactively slow down while quota is nearly exhausted.
            if int(resp.headers.get("X-RateLimit-Remaining", "1")) == 0:
                await asyncio.sleep(self._retry_after(resp))
            if next_cursor is None:                         # feed drained
                return total

    @staticmethod
    def _retry_after(resp: httpx.Response) -> float:
        # Prefer the server's explicit hint; fall back to a fixed pause.
        raw = resp.headers.get("Retry-After")
        if raw and raw.isdigit():
            return float(raw)
        reset = resp.headers.get("X-RateLimit-Reset")
        if reset and reset.isdigit():
            delta = int(reset) - int(datetime.now(timezone.utc).timestamp())
            return float(max(delta, 1))
        return 5.0

The ordering in sync() is the entire correctness argument: fetch → normalize → handoff (which validates and upserts) → then commit the cursor. Because the cursor is the only durable “we are done with this page” signal, and it moves last, any failure between fetch and commit is recovered by simply re-pulling — and the source_hash guarantees that re-pull is harmless. The handoff callback is where records enter schema validation and data quality checks and the idempotent upsert; this collector deliberately owns transport only.

Edge Cases and Billing Gotchas

  • Token expiry mid-pagination. A long pull can outlive a short-lived access token, and a 401 arriving on page 40 of 200 strands the run. Refreshing 60 seconds before the recorded expiry (as _auth_header does) absorbs both normal expiry and modest clock skew, so the token is never the reason a window of reads goes missing.
  • Rate-limit pause vs. cursor advance. On 429/503 the collector continues against the same cursor after waiting — it has not acknowledged the page, so waiting is safe. The dangerous inversion is advancing the cursor and then discovering the next page was throttled; that gap is a silent revenue leak. For sustained degradation rather than isolated throttling, escalate to the circuit breaker in error handling and retry workflows.
  • Duplicate page delivery. A retried request or a head-end that re-emits a boundary page will deliver reads the pipeline has already seen. The source_hash (meter + local timestamp + register) makes each re-delivery collapse to the same identity, so the downstream upsert is a no-op and no interval is double-counted.
  • Negative or rolled-over registers. Because the head-end sends cumulative lifetime registers, a value that decreases between reads is either a fixed-width rollover or a genuine fault — never valid consumption. The transport layer must not “fix” it; it passes the raw register through, and disposition happens in reading anomaly detection algorithms. When a read is missing entirely and no schedule can price the gap, control passes to fallback routing for missing rate data.

Verification Snippet

Prove the cursor never outruns the handoff. This test injects a handoff that fails on the second page and asserts the cursor stays pinned to the last successful acknowledgment, so a re-run resumes from the right place instead of skipping the failed window.

class FakeCursorStore:
    def __init__(self, start=None):
        self.value = start
        self.commits = []
    async def get(self):
        return self.value
    async def commit(self, cursor):
        self.value = cursor
        self.commits.append(cursor)


async def test_cursor_holds_when_handoff_fails():
    store = FakeCursorStore(start="A")
    seen = []

    async def flaky_handoff(records):
        if store.value == "B":            # fail while positioned on page B
            raise RuntimeError("downstream upsert rejected")
        seen.extend(records)

    # ... wire `collector` to a transport returning pages A -> B -> None ...
    with_pytest_raises = False
    try:
        await collector.sync(flaky_handoff)
    except RuntimeError:
        with_pytest_raises = True

    assert with_pytest_raises                     # the failure surfaced
    assert store.value == "B"                      # cursor did NOT advance past B
    assert "C" not in store.commits                # page after B was never acked

The invariant under test is the one that keeps billing whole: on any handoff failure the durable cursor must equal the last page that actually persisted, so the next run re-pulls the failed page rather than jumping past it.

Frequently Asked Questions

What happens if the AMI feed sends a duplicate read?
The collector computes a source_hash from the meter id, the local timestamp, and the cumulative register — the physical content of the read, never the transport envelope. A duplicated page or a retried request therefore produces identical hashes, and the downstream idempotent upsert turns the repeat into a no-op. No interval is double-counted, so a customer balance cannot inflate from a retry.
Should the cursor be committed before or after the reads are handed off?
Always after. The persisted cursor is the pipeline's only durable record of which page was successfully processed, so it must move last — fetch, normalize, hand off (validate and upsert), then commit. If the collector crashes between fetch and commit, it re-pulls the same page on restart, and the source_hash makes that re-pull idempotent. Committing first would strand any page whose handoff failed.
How do I handle OAuth token expiry during a long pull?
Track the token's expiry timestamp and refresh a small margin (about 60 seconds) before it lapses rather than waiting for a 401. A pull across hundreds of pages can easily outlive a short-lived access token, and reacting to a mid-page 401 wastes a request and complicates retry logic. Proactive refresh with a skew buffer keeps authentication invisible to the pagination loop.
What should the collector do on an HTTP 429?
Wait for the interval named in the Retry-After header (or derived from X-RateLimit-Reset) and then re-request the same cursor. Because a 429 arrives before the page is acknowledged, the cursor has not advanced, so pausing is safe and creates no data gap. Never advance the cursor and move on after a throttle — that silently skips the throttled window.
Why request cumulative registers instead of interval deltas from the API?
Cumulative lifetime registers are self-healing: interval energy is derived downstream as the difference between successive registers, so a single dropped page does not permanently lose consumption — the next register still reflects it. Vendor-reported deltas, by contrast, vanish forever if their page is lost. Registers also make rollover and negative-consumption faults detectable as a decrease, which a delta feed hides.

Up one level: AMI/AMR Feed Synchronization Protocols · Parent guide: Meter Data Ingestion & Validation Pipelines · Return to the utilitybilling.org home.