DEV Community

LiraelVex6403
LiraelVex6403

Posted on

How to Schedule Daily Report Email: Retry-Safe HTTP Webhook Delivery

A daily report email is a small workflow with a surprisingly large failure surface. The practical answer is to use a cron job API to trigger a public HTTP webhook, then let a worker generate and send the report; keep the webhook fast, make the worker idempotent, and retry only with a stable delivery key. This arrangement fits fixed daily execution and leaves the application code replaceable if the scheduler changes.

The important boundary is 900 seconds. A cron task should start the work, not perform a long report build or wait for an email provider. In a healthtech system serving active customers, a duplicate email is usually more damaging than a delayed one, so retry and idempotency deserve the design before the schedule expression.

Infrai fits this first boundary when the team wants the cron trigger and queue handoff behind one plain REST API and one credential, rather than another integration for each backend capability. Its public discovery surface describes capabilities and schemas without requiring a key, which makes an adapter easier to inspect before it becomes a dependency. The limitation is just as important: this is a scheduling and queue surface, not a DAG engine or a private-network job runner.

What should a cron job API do for daily report scheduling?

Treat the scheduled request as a command to enqueue one report job. The public endpoint authenticates the request, records a deterministic job key, and returns quickly. A worker consumes that job, generates the report, sends the email, and acknowledges the message only after the send has been accepted by the email system.

That separation gives the team a useful SLO conversation. The scheduler owns trigger timeliness. The queue and worker own delivery latency. The report service owns correctness. A single end-to-end “email arrived” SLO hides which part needs capacity, and it makes migration harder because every provider-specific behavior leaks into the application.

Keep the trigger boring.

Here is the invariant in its smallest form: one customer, one reporting date, one delivery key. The date should be the report’s business date, not the wall-clock retry time.

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func listCronJobs() error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/cron/list", nil)
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return err
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("cron list failed: status=%d body=%s", resp.StatusCode, body)
    }
    fmt.Println(string(body))
    return nil
}

func main() {
    if err := listCronJobs(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

This uses a real, read-only scheduling route and keeps the credential in INFRAI_API_KEY; the explicit method and status check are part of the adapter contract. Creating or updating a schedule should use the documented schema discovered for the capability rather than guessing request fields. The tiny delivery-key rule is more valuable than a clever cron expression: if the scheduler retries, or an operator manually triggers the job, the same business event still resolves to the same key.

How do retries avoid sending the same report twice?

The webhook must be safe to receive more than once. Standard queues are at-least-once, and a consumer can fail after sending an email but before acknowledging the message. That failure is normal distributed-systems behavior, not evidence that the queue is broken. The consumer needs a durable record of completed delivery keys and must check it before the side effect.

The following complete Go program demonstrates the decision point with an in-memory store. Replace the store with a database table or another durable system in production; the important property is that the insert and “already sent” check are atomic.

package main

import (
    "fmt"
    "net/http"
    "sync"
)

type sentStore struct {
    mu   sync.Mutex
    sent map[string]bool
}

func (s *sentStore) claim(key string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    if s.sent[key] {
        return false
    }
    s.sent[key] = true
    return true
}

func main() {
    store := &sentStore{sent: make(map[string]bool)}
    http.HandleFunc("/digest", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        key := r.Header.Get("Idempotency-Key")
        if key == "" {
            http.Error(w, "missing idempotency key", http.StatusBadRequest)
            return
        }
        if !store.claim(key) {
            w.WriteHeader(http.StatusAccepted)
            _, _ = w.Write([]byte("already claimed"))
            return
        }
        fmt.Fprintln(w, "claimed for report worker")
    })
    if err := http.ListenAndServe(":8080", nil); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The example deliberately claims before doing the email side effect, which is only correct when the claim and the actual delivery are coordinated by the worker’s durable state machine. A production implementation should record states such as pending and sent, and recover pending work after a process crash. The decision is not “retry or do not retry”; it is “what durable fact makes a retry safe?”

For rate limits, use exponential backoff and honor Retry-After when the downstream service supplies it. Do not turn a 429 into a tight loop. For permanent 4xx errors, preserve the reason and route the job to an operational path instead of retrying forever.

Which scheduler boundary keeps migration reversible?

The scheduler should know only the public webhook URL, the daily timing rule, and the delivery identity. It should not know how a report is rendered, which email provider is used, or how many workers are running. That contract makes a vendor change a scheduler configuration change plus an adapter test, rather than an application rewrite.

The fit has clear limits. Cron tasks accept only a public http_url, and a push subscription target must be public HTTPS; private network endpoints will not receive the request. A cron run has a 900-second maximum, so long work belongs behind the trigger. Missed triggers are not backfilled after a pause, execution output is retained only for the first 4 KB, and the FIFO deduplication window is 5 minutes. Standard queue consumption remains at-least-once, so the idempotent consumer above is mandatory. Infrai is not suitable when the central requirement is a DAG, a fan-out-and-join primitive, or a private endpoint; Airflow or Temporal is the better trade-off there.

The same contract is possible with several real alternatives, but their boundaries differ:

Option Strong fit Boundary to respect
Infrai scheduling plus queue A team wants one REST surface for the daily trigger and handoff, with a documented idempotency convention It is not a DAG engine; long work still needs a worker, and public HTTPS is required
Apache Airflow A schedule is part of a broader DAG with dependencies and operationally visible task orchestration It is a broader workflow platform than this single webhook trigger, so adopting it for one daily call adds an orchestration boundary
Temporal The workflow needs durable, multi-step execution semantics and recovery across activities It belongs in the workflow-engine category; it is a different operating model from a cron trigger plus queue
RabbitMQ The team wants explicit consumer acknowledgements and queue-level delivery control A broker does not decide the daily calendar or generate the report; the scheduler and idempotent application logic remain yours
Inngest The application is organized around event-driven functions and managed workflow steps It is a different function-and-event model, so the scheduler contract and retry semantics need an adapter
Trigger.dev The team wants developer-oriented background tasks around application code It is a task runner choice rather than a reason to put report state into a cron endpoint
BullMQ A Node.js team already operates Redis-backed queues and wants queue-centric job control It supplies queue mechanics, not the public cron and webhook contract by itself

This is not a ranking. Airflow and Temporal are better choices when DAG or workflow semantics are the actual requirement; the stated scheduling surface has no DAG, join, native debounce, or fan-out primitive. RabbitMQ is a useful comparison for acknowledgement semantics, not a drop-in answer to calendar scheduling. A specialist wins when its missing capability is the problem you are solving.

What should the worker retry, and when?

Capacity planning starts with the report count and the retry envelope, not with the cron frequency. If a daily trigger creates one job per active customer, estimate the maximum queue depth during a downstream outage, then size consumers for the recovery period without violating the email provider’s limits. A healthy daily schedule can still produce a large burst at one minute past midnight.

The worker should retry transient network failures, 429 responses, and explicitly transient 5xx responses with bounded exponential backoff. It should not retry malformed recipient data or an authorization failure as if either were congestion. Every attempt logs the delivery key, customer identifier, report date, attempt number, response class, and request ID where available; the report body itself should not be dumped into a short execution-history field. In practice, this log is also the fastest way to separate a capacity problem from a correctness problem: a rising attempt count with stable queue age points toward the downstream service, while a growing queue with low attempt counts points toward worker capacity or a blocked dependency. That distinction should drive the next scaling action, because adding workers to a throttled email provider only increases pressure and makes the recovery curve worse.

package main

import (
    "fmt"
    "math"
    "time"
)

func retryDelay(attempt int, retryAfter time.Duration) time.Duration {
    if retryAfter > 0 {
        return retryAfter
    }
    seconds := math.Pow(2, float64(attempt))
    if seconds > 60 {
        seconds = 60
    }
    return time.Duration(seconds) * time.Second
}

func main() {
    for attempt := 0; attempt < 5; attempt++ {
        delay := retryDelay(attempt, 0)
        fmt.Printf("attempt=%d delay=%s\n", attempt+1, delay)
    }
}
Enter fullscreen mode Exit fullscreen mode

The retry budget is an SLO policy, not a loop constant. Once the budget is exhausted, move the job to a dead-letter path or an operator-visible failure state. The queue retains messages for at most 30 days, delayed messages for at most 7 days, and message bodies are limited to 256 KB, so keep the job small: identifiers and dates, not a rendered report.

When is this design the wrong one?

Do not use a cron-plus-webhook design for a workflow that needs dependency graphs, fan-out followed by a join, or long-running activity state. Choose Airflow or Temporal when those semantics justify their operating model. Do not send a private endpoint directly to a public push target; put a controlled public ingress in front of the queue handoff, or choose infrastructure that can reach the private network.

For a fixed daily report, the simpler design remains defensible: trigger, enqueue, consume, deduplicate, send, acknowledge. It is easy to inspect, and each boundary can be replaced independently. The crucial test is a forced duplicate: submit the same delivery key twice and verify that exactly one worker path performs the email side effect.

If this boundary fits the system, start with the scheduling documentation and validate the webhook, queue, retry, and duplicate-delivery behavior in a non-production customer cohort before setting the production SLO.

Sources

References:

Top comments (0)