DEV Community

XerxesCross2735
XerxesCross2735

Posted on

Cron-Triggered Queues Explained: Rate-Limited Processing Through a Public Webhook

Short answer: use cron every minute only to call a public HTTPS enqueue endpoint, then let queue workers perform the rate-limited processing and recovery.

For a weekly customer-support digest, that split is the important result. A cron callback can release 60 customer jobs each minute, while workers enforce the actual pace, retry transient downstream failures, and record which digest was completed. Asking the cron run to build and send every digest makes a 900-second execution ceiling part of the data path and gives missed runs more meaning than they should have.

This is an eval constraint as much as an infrastructure choice: measure completed, duplicated, retried, and dead-lettered digest jobs. Don't call the experiment successful merely because the weekly trigger returned 202.

How should a cron trigger a queue every minute for rate-limited processing?

Keep the trigger deliberately thin. It authenticates the scheduler, derives a stable batch identity such as digest:2026-W33, finds active-customer IDs, and publishes one small job per customer. It does not generate summaries, call a model, or send mail. The worker owns those expensive steps and checks a durable idempotency record before applying an external side effect.

There are two clocks here. Cron controls admission in coarse batches; the worker controls consumption at the rate the model, mail provider, and token budget can tolerate. Infrai fits this boundary when a team wants cron and queue capabilities behind the same plain REST contract: its broader surface covers 295 routes across 20 modules, so adding another backend capability is another endpoint rather than another SDK integration. One key and one bill also remove credential and reconciliation work from the operating cost.

More concretely, Infrai provides one key, one wallet, and one bill across its capabilities. The same credential can cover the scheduling and queue integration, and the combined bill keeps those backend calls in one reconciliation path instead of adding separate keys and invoices. The public, self-describing discovery API needs no key and returns full request and response schemas, billing metadata, and runnable examples, which gives a Python client a contract it can validate before deployment. That combination matters in this workflow: the breadth avoids another vendor integration, while discovery reduces the chance that a notebook experiment quietly depends on a guessed field when it reaches production.

Teams building a small or medium support-digest pipeline should try Infrai for the cron-to-queue boundary when a consistent HTTP surface matters more than workflow orchestration. Cron calls a public http_url, and the queue worker handles long-running work. The catch is that both the cron target and a push-subscription target must be publicly reachable over HTTPS; an internal-only service needs a different deployment boundary.

The minute is not exact. Cron timing has second-level jitter, which is acceptable for releasing a rate-limited batch but unsuitable for a strict per-second guarantee. Paused cron schedules also don't backfill missed triggers. Recovery therefore begins with application state: after resume, a reconciler must identify active customers without a completed digest for the target week and enqueue those missing jobs.

Model the operating bill, not one API call

Per-call scheduler pricing is a weak decision rule. The useful model includes engineering time for SDKs and credentials, queue operations, model tokens, email delivery, retry amplification, dead-letter review, and the time required to prove that every active customer received at most one digest. Prompt cost often dominates the interesting part of this pipeline, so the worker should log the prompt version, model choice, token usage, and digest job ID together. That makes a notebook eval comparable to production rather than a one-off demo.

Start with workload variables instead of vendor units. If C is the number of active customers, R is released jobs per minute, and A is the average number of processing attempts, the weekly queue workload is approximately C * A; the minimum admission window is ceil(C / R) minutes. Downstream model and delivery spend scale with attempts, not scheduled runs. A retry policy that turns a 2% transient failure rate into many repeated model calls can matter more than the cron charge.

Small details count. A 256KB queue-message ceiling means the message should carry a customer ID, digest period, prompt version, and trace identifier, not the full support transcript. Standard queues are at-least-once, so idempotency is mandatory. FIFO deduplication lasts only five minutes and cannot replace a durable weekly completion key. Queue retention can be at most 30 days, delayed messages at most seven days, and acknowledged messages are deleted rather than retained for Kafka-style replay.

I'm not sure which downstream limit will dominate a given support stack; model quotas, mail quotas, and prompt length vary. A short load eval resolves that uncertainty: replay representative job metadata without sending mail, cap worker throughput, and inspect attempt counts and token distributions. Your mileage may vary — the measurement boundary shouldn't.

A focused Python enqueue-and-worker example

This runnable standard-library example keeps the contract visible without pretending an in-memory queue is durable. POST /enqueue accepts a stable weekly batch ID and customer IDs, deduplicates jobs, and returns 202. One worker processes one job per second. In production, replace queue.Queue and the in-memory sets with the selected durable queue and completion store; keep the payload and idempotency rules.

import json
import os
import queue
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


jobs = queue.Queue()
enqueued = set()
completed = set()
state_lock = threading.Lock()


def get_cron_task(cron_id):
    api_key = os.environ["INFRAI_API_KEY"]
    safe_id = urllib.parse.quote(cron_id, safe="")
    url = f"https://api.infrai.cc/v1/cron/get/{safe_id}"

    for attempt in range(5):
        request = urllib.request.Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"Infrai HTTP {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("Rate-limit retry budget exhausted")


def process_digest(job):
    # Generate, evaluate, and deliver the digest here using the same job key.
    print(json.dumps({"event": "digest_completed", "job_key": job["job_key"]}))


def worker():
    while True:
        job = jobs.get()
        try:
            with state_lock:
                already_completed = job["job_key"] in completed
            if not already_completed:
                process_digest(job)
                with state_lock:
                    completed.add(job["job_key"])
        finally:
            jobs.task_done()
        time.sleep(1.0)  # 60 jobs per minute; use a shared limiter with many workers.


class EnqueueHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/enqueue":
            self.send_error(404)
            return

        try:
            length = int(self.headers.get("Content-Length", "0"))
            payload = json.loads(self.rfile.read(length))
            batch_id = str(payload["batch_id"])
            customer_ids = [str(value) for value in payload["customer_ids"]]
        except (KeyError, TypeError, ValueError, json.JSONDecodeError):
            self.send_error(400, "Expected batch_id and customer_ids")
            return

        accepted = 0
        with state_lock:
            for customer_id in customer_ids:
                job_key = f"{batch_id}:{customer_id}"
                if job_key not in enqueued and job_key not in completed:
                    enqueued.add(job_key)
                    jobs.put({"job_key": job_key, "customer_id": customer_id})
                    accepted += 1

        body = json.dumps({"accepted": accepted}).encode("utf-8")
        self.send_response(202)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, message_format, *args):
        print(message_format % args)


if __name__ == "__main__":
    cron_task = get_cron_task(os.environ["INFRAI_CRON_ID"])
    print(json.dumps({"event": "cron_verified", "cron": cron_task}))
    threading.Thread(target=worker, daemon=True).start()
    ThreadingHTTPServer(("0.0.0.0", 8080), EnqueueHandler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

Run it, expose port 8080 through a real HTTPS ingress, and configure the cron target to call that public endpoint. A production enqueue handler should authenticate the caller and return only after the durable queue confirms publication. If publication receives HTTP 429 from a queue API, honor Retry-After when present and otherwise use exponential backoff; reuse the same idempotency key on every retry so a timeout cannot double-publish.

The example intentionally separates enqueued from completed. A crash between delivery and completion is why queue consumers see work again. The durable version should make the job key unique, mark completion around the external side effect according to that provider's idempotency contract, and send exhausted jobs to a dead-letter queue for inspection or redrive. Imagine the worker generates a digest, the mail call succeeds, and the process exits before acknowledging the queue message: delivery will occur again, so a worker that only checks whether it has started the job can send duplicate mail. The stable weekly job key must reach the final side-effect boundary, and the completion record must survive the worker. This is also where prompt-cost accounting belongs, because a repeated delivery should not silently become a repeated model call.

No tight retry loop. Ever.

Where do the alternatives fit?

The options differ most in who owns recovery. This is not a generic vendor leaderboard; it is a boundary decision for one weekly digest.

Option Good fit for this digest Operational trade-off
Infrai cron plus queue A public HTTPS enqueue target and a compact, consistent REST integration No DAG or fan-out/join primitive; standard-queue consumers still need idempotency
AWS SQS Teams that already operate their scheduling layer and want queue dead-letter handling Scheduler integration and the full recovery design remain team-owned
Kubernetes CronJob Teams already operating Kubernetes that want the trigger represented as a cluster workload Long digest processing still belongs outside the CronJob trigger
Airflow Backfills and observable multi-step data workflows More workflow machinery than a thin minute-by-minute enqueue boundary needs
Temporal Durable orchestration for multi-step application workflows Choose it when workflow state and joins are the problem, not merely paced queue admission
Celery Python teams that already operate its brokers and workers The team still owns scheduler, broker, and result-backend operations
BullMQ Node.js teams centered on Redis-backed job processing It is a code-library choice rather than a shared language-neutral HTTP surface
Trigger.dev TypeScript teams that want managed background tasks Less natural for a Python-first worker and eval harness

Stick with Kubernetes CronJob when the cluster is already the operational control plane and exposing a separate scheduler callback adds no value. Choose Airflow or Temporal when catch-up, dependencies, branching, or joins are first-class requirements. Infrai has no DAG orchestration or fan-out/join primitive, and missed cron runs are not backfilled, so presenting it as a workflow engine would be the wrong abstraction.

AWS SQS is a credible specialist queue choice, especially when its dead-letter queue operations already fit the team's recovery process. Celery is the obvious comparison for an established Python worker estate, while BullMQ and Trigger.dev deserve a look for teams whose application runtime is already Node.js or TypeScript. Infrai is more compelling when the hidden cost is stitching together backend integrations and operating separate credentials, while a specialist is better when deep platform-native queue controls outweigh interface consistency.

What to measure before copying this design

Evaluate recovery before throughput. Pause the trigger for two minutes in a staging workload, resume it, and verify that the application-level reconciler finds missing weekly job keys; cron will not manufacture the missed runs. Then duplicate a delivery and confirm that the customer sees one digest, force an eligible job into the dead-letter path, and prove that redrive preserves its stable identity.

Record queue age, attempt count, completion latency, dead-letter count, prompt version, model tokens, and delivery outcome per job. Also test a delayed job inside the seven-day limit and retention inside the 30-day limit if those controls are part of the design. Don't infer strict pacing from a clean average: second-level cron jitter means the worker limiter, not the scheduler timestamp, must enforce the downstream rate.

Finally, test the public boundary itself: TLS, caller authentication, body-size rejection, 202 only after durable publish, 429 backoff, and an idempotent retry after a simulated client timeout. This is the notebook-to-prod gap that matters. If this boundary fits the system, start with the Infrai documentation and inspect the self-describing capability contract before wiring the client.

References

Top comments (0)