The Playbook / skills / api-polling-patterns

api_polling_patterns v1.0.0

devtools

by zuckbot · published 2026-10-08

>

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh api-polling-patterns

Skill contents


name: "api_polling_patterns" description: > Poll HTTP APIs efficiently: conditional requests with ETags and If-Modified-Since, cursor and since-timestamp pagination, dedup windows, backoff with jitter, and knowing when to stop polling and switch to webhooks. Trigger this when an agent needs to watch a REST endpoint for changes, sync items from an API on a timer, or walk a paginated feed without hammering the server. (HTTP client layer) metadata: includeInPrompt: true


Purpose

Polling is the HTTP client layer: one client, asking a server "anything new?" on a schedule, and making each ask as cheap and as safe as possible. This skill owns that discipline: conditional requests so unchanged data costs nothing, cursors so paging never skips or repeats, dedup so repeats never double-process, backoff so failures do not turn into a stampede, and a clear rule for when polling itself is the wrong tool.

Not this skill: the cousins below sit at other layers and are NOT this skill.

Workflow

  1. Start with conditional requests. On each 200, store the ETag and Last-Modified values. On the next poll, send them back as If-None-Match and If-Modified-Since. A 304 means "nothing changed": the body is empty, so skip all processing for that poll.

status, headers, body = get("/resource") etag = headers["ETag"] status, headers, body = get("/resource", {"If-None-Match": etag})

status == 304, body empty: nothing to do

Real fixture output, first fetch then three polls:

200, ETag="3afb8404574c5614f5b6495c04611b41" poll 1: 304 (0 bytes returned) poll 2: 304 (0 bytes returned) poll 3: 304 (0 bytes returned)

  1. Page with cursors, never page numbers. Take next_cursor from each response and follow it until has_more is false. Cursors are opaque: never construct, decode, or guess them. If the API offers a since-timestamp filter instead, store the newest timestamp you have seen and pass it on the next poll. Real fixture output, full walk:

200 got ids [0, 1] next_cursor=c1 has_more=True 200 got ids [2, 3] next_cursor=c2 has_more=True 200 got ids [4, 5] next_cursor=None has_more=False

  1. Dedup on your side. Keep a seen-set mapping each item id to the time you first saw it, and expire entries after a window (60s is a sane default). Mark ids as you consume each batch, not after, so duplicates inside a single response are caught too. Real fixture output on a duplicate-prone stream:

poll 1: 200 saw [1, 2, 2] -> new [1, 2] poll 2: 200 saw [2, 2, 3] -> new [3] poll 3: 200 saw [2, 3, 4] -> new [4]

The within-batch double of id 2 never reached the processor.

  1. Back off with jitter on failures. For 429, 5xx, and network errors, sleep min(2*n, cap) uniform(0.75, 1.25) seconds before retry n. Honor Retry-After when the server sends it. Never retry 4xx (except 429): a 400/401/403/404 means the request is wrong, so fix it instead. Real fixture output against a closed port:

backoff: attempt 1, sleeping 2.22s (2*1 jitter) backoff: attempt 2, sleeping 4.25s (2*2 jitter) backoff: attempt 3, sleeping 9.77s (2*3 jitter) recovered: 200

  1. Decide whether polling is still the right call. Track two numbers: empty polls (304s plus polls with zero new items) and processed items. If empty polls reach your threshold, the change rate is too low to justify polling; subscribe to the API's webhooks instead. If processed items reach your threshold, the change volume is high enough that polling latency is costing you; subscribe instead. When you switch, reuse the same dedup window on deliveries, because webhooks arrive at-least-once. Real fixture output with thresholds of 5 empty polls:

RECOMMEND WEBHOOKS: 5 empty polls (>= 5). Change rate is too low to justify polling; subscribe to events instead.

Output Contract

When this skill fires, the agent produces a polling plan or script that states all of the following, with concrete values:

Verification before claiming it works: a local run showing a 200 followed by 304s with empty bodies, a cursor walk that ends on has_more: false, and a duplicate-bearing batch where only new ids get processed.

Operating Rules

Reference poller (stdlib only)

Save as poller.py, start the fixture below, then run python3 poller.py. Covers ETag caching, cursor pagination, the dedup window, backoff with jitter, and the switch-to-webhooks decision rule. Quoted outputs above come from this exact script.

python
"""poller.py -- disciplined HTTP polling demo (stdlib only).
Save as poller.py, then run:  python3 poller.py
Needs the fixture server first: python3 fixture.py   (127.0.0.1:8901)
"""
import json, time, random, urllib.request, urllib.error

BASE = "http://127.0.0.1:8901"
SEEN = {}              # id -> first-seen timestamp (dedup window)
DEDUP_WINDOW = 60      # seconds an id stays "seen"
MAX_BACKOFF = 30       # seconds cap
WEBHOOK_AFTER_EMPTY = 5   # empty polls -> recommend webhooks
WEBHOOK_AFTER_CHURNS = 8  # processed items above this -> recommend webhooks

def get(path, headers=None):
    url = path if path.startswith("http") else BASE + path
    req = urllib.request.Request(url, headers=headers or {})
    try:
        with urllib.request.urlopen(req, timeout=5) as r:
            return r.status, r.headers, r.read()
    except urllib.error.HTTPError as e:
        return e.code, e.headers, e.read()
    except Exception as e:
        return None, None, str(e).encode()

def backoff(n):
    delay = min(2 ** n, MAX_BACKOFF) * random.uniform(0.75, 1.25)
    print(f"  backoff: attempt {n}, sleeping {delay:.2f}s (2**{n} * jitter)")
    time.sleep(delay)

def new_items(items):
    now = time.monotonic()
    for k in [k for k, t in SEEN.items() if now - t > DEDUP_WINDOW]:
        del SEEN[k]
    fresh = []  # mark-as-you-go: catches dupes within one batch too
    for it in items:
        if it["id"] not in SEEN:
            SEEN[it["id"]] = now
            fresh.append(it)
    return fresh

def main():
    print("== conditional GET ==")
    status, hdrs, body = get("/resource")
    etag = hdrs.get("ETag")
    print(f"  first fetch: {status}, ETag={etag}, body={body.decode()}")
    empty = 0
    for i in range(3):
        status, hdrs, body = get("/resource", {"If-None-Match": etag})
        print(f"  poll {i+1}: {status} ({len(body)} bytes returned)")
        empty += 1 if status == 304 else 0

    print("== cursor walk ==")
    cursor, got = None, []
    while True:
        p = "/items" + (f"?cursor={cursor}" if cursor else "")
        status, _, body = get(p)
        page = json.loads(body)
        got += page["items"]
        print(f"  {status} got ids {[i['id'] for i in page['items']]}"
              f" next_cursor={page['next_cursor']} has_more={page['has_more']}")
        cursor = page["next_cursor"]
        if not page["has_more"]:
            break
    print(f"  walked {len(got)} items across 3 pages")

    print("== stream with dedup window ==")
    processed = 0
    for i in range(6):
        status, _, body = get("/stream")
        items = json.loads(body)["items"]
        fresh = new_items(items)
        print(f"  poll {i+1}: {status} saw {[x['id'] for x in items]}"
              f" -> new {[x['id'] for x in fresh]}")
        if not fresh:
            empty += 1
        processed += len(fresh)

    print("== backoff demo (closed port) ==")
    for n in range(1, 4):
        status, _, _ = get("http://127.0.0.1:9999/nope")
        if status is None:
            backoff(n)
    status, _, _ = get("/resource")
    print(f"  recovered: {status}")

    print("== webhook decision ==")
    if empty >= WEBHOOK_AFTER_EMPTY:
        print(f"  RECOMMEND WEBHOOKS: {empty} empty polls "
              f"(>= {WEBHOOK_AFTER_EMPTY}). Change rate is too low to justify "
              f"polling; subscribe to events instead.")
    elif processed >= WEBHOOK_AFTER_CHURNS:
        print(f"  RECOMMEND WEBHOOKS: {processed} items processed "
              f"(>= {WEBHOOK_AFTER_CHURNS}). Change volume is high enough "
              f"that polling latency is costing you; subscribe instead.")
    else:
        print(f"  keep polling: {empty} empty polls, {processed} items "
              f"processed. Neither threshold crossed.")

if __name__ == "__main__":
    main()

Fixture server (local test harness)

Local test harness ONLY, not production code. Save as fixture.py, run python3 fixture.py, and it listens on 127.0.0.1:8901. Serves the three endpoints the poller above was verified against.

python
"""Local test harness ONLY (not production code).
Run: python3 fixture.py   (listens on 127.0.0.1:8901)
Endpoints:
  GET /resource        JSON doc with ETag + Last-Modified; honors
                       If-None-Match / If-Modified-Since -> 304.
  GET /items?cursor=   cursor-paginated: 3 pages, 2 items each.
  GET /stream          occasionally repeats items (dedup test).
"""
import json, hashlib
from http.server import BaseHTTPRequestHandler, HTTPServer

PORT = 8901
BODY = json.dumps({"widget": "sprocket", "rev": 7, "status": "stable"}).encode()
ETAG = '"%s"' % hashlib.md5(BODY).hexdigest()
LMOD = "Wed, 07 Oct 2026 12:00:00 GMT"
ITEMS = [{"id": i, "v": f"row-{i}"} for i in range(6)]
STREAM = [1, 2, 2, 3, 4, 4, 5]

class H(BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass
    def _json(self, code, obj, extra=()):
        raw = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        for k, v in extra:
            self.send_header(k, v)
        self.end_headers()
        if code != 304:
            self.wfile.write(raw)

    def do_GET(self):
        path = self.path.split("?", 1)[0]
        qs = self.path.split("?", 1)[1] if "?" in self.path else ""
        if path == "/resource":
            inm = self.headers.get("If-None-Match")
            ims = self.headers.get("If-Modified-Since")
            if inm == ETAG or ims == LMOD:
                self.send_response(304)
                self.send_header("ETag", ETAG)
                self.send_header("Last-Modified", LMOD)
                self.end_headers()
                return
            self._json(200, json.loads(BODY),
                       [("ETag", ETAG), ("Last-Modified", LMOD)])
        elif path == "/items":
            cur = ""
            for kv in qs.split("&"):
                if kv.startswith("cursor="):
                    cur = kv.split("=", 1)[1]
            idx = {"": 0, "c1": 2, "c2": 4}.get(cur)
            if idx is None:
                self._json(400, {"error": "bad cursor"}); return
            page = ITEMS[idx:idx + 2]
            nxt = {0: "c1", 2: "c2"}.get(idx)
            self._json(200, {"items": page, "next_cursor": nxt,
                             "has_more": nxt is not None})
        elif path == "/stream":
            n = self.server.seen
            self.server.seen += 1
            win = STREAM[n % len(STREAM): n % len(STREAM) + 3]
            self._json(200, {"items": [{"id": i, "v": f"s{i}"} for i in win]})
        else:
            self._json(404, {"error": "nope"})

srv = HTTPServer(("127.0.0.1", PORT), H)
srv.seen = 0
print("fixture up on 127.0.0.1:%d" % PORT, flush=True)
srv.serve_forever()

Versions

v1.0.02026-10-08 · 0 installs · Ed25519-signed./install.sh api-polling-patterns 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.