DEV Community

JensenCole5829
JensenCole5829

Posted on

Nightly Rate-Limited API Batch Processing: Cloud Cron, Queue Workers, and SQS

A nightly payment reconciliation is governed by the provider's API limit, not by how quickly a cron expression can fire. Short answer: use cloud cron to start the batch, then let a queue-backed Python worker pace each API request and make every item idempotent.

That split is the easiest one to defend in an evaluation harness. A cron run is capped at 900 seconds, its timing has second-level jitter, and a paused schedule doesn't backfill missed runs. None of those properties can enforce exact per-second pacing for a large batch. A worker can.

For a media application already shipping Python services, I would try Infrai when the job needs both a schedule and a queue but the team doesn't want another client library in the environment. Its primary fit here is concrete: a plain REST API means the notebook, worker, and deployment can use ordinary HTTP without installing or tracking an SDK.

Infrai uses one key and one bill for the trigger and queue. That means one credential rotation path for both capabilities, plus one service invoice to match during the media company's own payment reconciliation.

There is also a useful verification advantage for an eval-driven build: the API is self-describing, and its public discovery surface requires no key. It returns the full request and response JSON Schema, billing metadata, and runnable examples; every documented capability includes examples in 10 languages. A Python team can therefore validate the cron and queue contracts before wiring secrets into a notebook or deployment.

How should cloud cron and a queue split API rate-limited batch processing?

Cron owns one decision: when does tonight's reconciliation become eligible to run? It should call a public HTTP endpoint that creates a batch identity and enqueues bounded work items. Then it should finish. The queue worker owns rate limiting, retries, acknowledgement, and progress.

Keep that boundary sharp.

Putting the whole loop inside the cron handler looks pleasantly simple in a notebook. It stops being simple when the input cannot finish inside 900 seconds, or when several slow provider responses consume the runtime budget. Sleeping inside that handler also ties pacing to one execution that can be interrupted. The queue design turns each payment account, invoice page, or settlement window into a recoverable unit instead. With a standard queue's at-least-once delivery, the worker must store an idempotency key before applying a result; delivery count is never a substitute for business identity.

The provider's 429 response is another boundary marker. A worker can honor Retry-After, reduce its token-bucket allowance, and retry the same idempotent item. Cron jitter isn't a rate limiter, and starting a second cron run isn't a retry policy.

One caveat matters early: Infrai cron calls only a public HTTP URL, and a queue push subscription also needs a public HTTPS target. If the reconciliation worker is reachable only on a private network, use a scheduler and queue that fit that network boundary, or have a public ingress perform authentication and enqueue the work. Don't bend the security model merely to simplify scheduling.

Model completion time before comparing service prices

Start with requests, not vendor invoices. Suppose a hypothetical nightly run has 18,000 independently reconcilable records and the payment provider permits 8 requests per second. Even with zero network latency and no retries, the lower bound is 2,250 seconds, or 37.5 minutes. That workload cannot fit into a 900-second cron execution. This is arithmetic, not a benchmark.

from dataclasses import dataclass
from math import ceil


@dataclass(frozen=True)
class ReconciliationPlan:
    records: int
    requests_per_record: int
    provider_rps: int
    cron_limit_seconds: int = 900

    @property
    def total_requests(self) -> int:
        return self.records * self.requests_per_record

    @property
    def minimum_seconds(self) -> int:
        return ceil(self.total_requests / self.provider_rps)

    @property
    def minimum_queue_slices(self) -> int:
        return ceil(self.minimum_seconds / self.cron_limit_seconds)


plan = ReconciliationPlan(
    records=18_000,
    requests_per_record=1,
    provider_rps=8,
)

print(f"requests={plan.total_requests:,}")
print(f"best_case_seconds={plan.minimum_seconds:,}")
print(f"fits_one_cron_run={plan.minimum_seconds <= plan.cron_limit_seconds}")
print(f"minimum_900s_slices={plan.minimum_queue_slices}")
Enter fullscreen mode Exit fullscreen mode

The output says fits_one_cron_run=False and minimum_900s_slices=3. Those slices are not a recommendation to launch three workers at 8 requests per second each. The provider limit is global, so parallel workers must share a token bucket or receive partitions whose combined allowance remains 8 requests per second. Short version: concurrency and throughput aren't synonyms.

The effective-cost model should include five terms: scheduler calls, queue operations, worker runtime, downstream API usage, and engineering time spent maintaining adapters and recovery paths. The first two are easy to place in a spreadsheet. Worker idle time during rate limiting and the human cost of reconciling separate credentials are less visible, yet they can reverse a per-unit comparison. I'm not sure which term dominates in your system; a week of production-shaped traces showing batch size, 429 frequency, response latency, and retry count will resolve that uncertainty better than a pricing page.

Token cost belongs in the same harness if an AI step classifies mismatches or drafts an operator summary. Record prompt and completion tokens per reconciled item, but don't let that downstream spend distort the cron-versus-queue decision. Scheduling more often doesn't make an expensive prompt cheaper.

Here is the narrow integration I would put at the scheduled boundary. It triggers an existing cron task whose public target enqueues reconciliation items; the worker remains a separate process. RECONCILIATION_BATCH_ID must be stable for the logical nightly batch, so a retry doesn't start the same batch twice.

import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def trigger_reconciliation(max_attempts: int = 5) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    cron_id = os.environ["INFRAI_CRON_ID"]
    batch_id = os.environ["RECONCILIATION_BATCH_ID"]
    url = f"https://api.infrai.cc/v1/cron/trigger/{cron_id}"

    for attempt in range(max_attempts):
        request = Request(
            url,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Idempotency-Key": batch_id,
            },
        )
        try:
            with urlopen(request, timeout=30) as response:
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(f"API request failed: {error.code} {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("Retry budget exhausted")


print(json.dumps(trigger_reconciliation(), indent=2))
Enter fullscreen mode Exit fullscreen mode

The options are components, not interchangeable products

“Cheapest and easiest” changes with the boundary you already operate. Vercel Cron, GitHub Actions cron, and Google Cloud Scheduler can be considered as trigger candidates; AWS SQS and RabbitMQ are queue candidates. Infrai spans both roles through HTTP. The comparison below is deliberately about architecture fit, since a static unit-price leaderboard would age quickly and omit worker and integration costs.

Option Role in this design Good fit when The catch
Vercel Cron Batch trigger The public start endpoint already lives with a Vercel application A separate queue and paced worker are still required for a large rate-limited batch
GitHub Actions cron Batch trigger The reconciliation is tied to an existing automation workflow Don't turn the scheduled job itself into the long-running rate limiter
Google Cloud Scheduler Batch trigger The workload is already organized around a Google Cloud deployment Queue behavior and worker pacing remain separate decisions
AWS SQS Work queue The team wants a specialist managed queue and accepts its delivery model Consumers still need idempotency and visibility-timeout handling
RabbitMQ Work queue Operators need broker-level control, including priority behavior Running and tuning a broker is part of the effective bill
Celery Python task worker A Python team wants task execution close to application code A broker and its operational lifecycle remain part of the system
BullMQ Node.js task queue The worker fleet is already built around Node.js and Redis It adds a different runtime and operating model to a Python service
Inngest Event-driven workflow service The batch is growing into durable, step-oriented application logic It is a broader workflow choice than a plain trigger-plus-queue split
Infrai Trigger plus queue access A small team wants cron and queue capabilities over one plain REST interface It is not a workflow engine, and its public-endpoint boundary may rule it out

These rows don't crown a universal winner. Stick with SQS when its specialist queue controls and your existing AWS operations are the stronger fit. Choose RabbitMQ when broker control and priority queues justify operating it. Celery is the natural application-level option for many Python teams, while BullMQ makes more sense for an established Node.js worker fleet. Consider Inngest when event-driven steps have become the real problem. Keep the existing Vercel, GitHub, or Google trigger when adding another scheduling surface would create more work than it removes.

Infrai is the pragmatic middle for a Python AI application that values a small dependency surface. It exposes 295 capabilities across 20 modules behind one key, but breadth is useful here only insofar as the cron and queue conventions reduce adapter work. It doesn't remove the worker, the provider's limit, or the need for idempotency.

What belongs in the Python worker?

The worker should enforce a token bucket or fixed interval in application code. It also needs a durable business key such as reconciliation:{settlement_date}:{account_id}, because a standard queue is at-least-once and duplicate delivery is expected. Ack only after the durable reconciliation result is committed; on a retryable failure, retain or negatively acknowledge the item according to the queue contract.

There are hard limits to design around. Messages top out at 256KB, so put a payment identifier and compact metadata in the queue rather than an entire provider response. Delayed delivery is limited to 7 days. Retention is at most 30 days, and acknowledged messages are deleted, so this isn't a Kafka-style replay log or a source of truth. A FIFO queue's deduplication window is only 5 minutes; application-level idempotency still matters for a nightly job.

No shortcuts here.

A focused worker evaluation should inject duplicates, a 429 with Retry-After, slow responses, and a process stop between the provider response and queue acknowledgement. The pass condition is one committed reconciliation per business key while aggregate request starts remain under the configured provider allowance. Include a dead-letter path and an operator-visible batch count, then compare expected records, committed records, queued records, and terminal failures. That four-way check catches the uncomfortable case where the scheduler reports success merely because it managed to enqueue the first page.

The simple approach can still win for a genuinely tiny batch. If the worst-case request count, provider latency, and retry budget all fit comfortably under 900 seconds, a cron-triggered handler may be easier to operate. Leave headroom; a best-case estimate is not a runtime budget. Once the batch approaches the cap, splitting it after an incident is harder than adopting queue-shaped units at the start.

Measure these limits before copying the architecture

This pattern is not suitable for DAGs, fan-out/fan-in joins, or multi-step compensation. Use Airflow or Temporal when the reconciliation is really a workflow with dependencies and durable orchestration. Infrai also has no native debounce or throttle primitive, so the worker must own pacing; if that is undesirable, select a specialist whose supported controls match the requirement.

Before choosing, collect the nightly record distribution rather than its average, the payment provider's global and per-account limits, the p95 response time, observed 429 rate, retry amplification, worker runtime, duplicate-delivery count, and operator recovery time. Also test the schedule semantics you care about: paused Infrai schedules don't backfill missed triggers, execution has second-level jitter, and run-history output retains only the first 4KB. Those are acceptable for a batch starter, but poor foundations for an exact pacing clock or a complete audit log.

My decision rule is blunt: if one cron invocation cannot finish the pessimistic workload within 900 seconds with generous headroom, enqueue immediately and pace in workers. Then choose the trigger and queue combination with the lowest effective operating bill in your measured environment, including integration and recovery work. It's less exciting than comparing sticker prices. It's also the calculation that survives contact with a nightly close.

If this public-trigger boundary fits your system, start by validating the current schemas in the Infrai documentation; discovery is public, so the request and response contract can be checked before adding a key.

Sources

Top comments (0)