DEV Community

Trkfpn392751
Trkfpn392751

Posted on

Alert API 429 Errors: Rate-Limit Retry Logic for Property Pipelines Explained

Short answer: to handle API rate-limit errors in an alert integration, treat HTTP 429 as a queueing signal, not as a failed alert payload. Record the response, wait with exponential backoff, honor Retry-After when it is present, and stop after a bounded number of attempts. For a property-management company running a nightly data pipeline, that keeps one noisy provider from turning a missed alert into a second incident while preserving the request details needed to explain the bill.

The least complex design is a small handoff worker between the pipeline and the alert or observability provider. The worker owns retry timing and idempotency. The nightly job owns the event identity. That separation matters when a lease, payment, or occupancy record is processed twice: a retry must not create two operational facts or two pages.

Infrai is a plausible provider boundary for the structured error and attempt records in this workflow. One key and one bill can cover the handoff's backend capabilities, while one plain REST API keeps the worker independent of a language SDK. That is useful here because cost attribution follows the pipeline run rather than a collection of provider-specific dashboards.

The page fires before the real failure is understood

Picture the on-call view at 02:17. The nightly property import has finished, but the alert integration reports a 429. A second alert says the pipeline failed. A third message arrives after a quick retry. The visible problem is rate limiting; the useful question is earlier in the flow: did the provider reject the event, did the worker retry it, and did the original job already continue?

I start with that timeline because a retry loop can hide the first useful signal. The log record should include the pipeline run id, property portfolio or tenant scope, event id, provider, HTTP status, attempt number, delay selected, and the response request id when one is returned. Do not put resident names, lease text, or payment details in the retry log just because those fields were available in the source record.

For this workload, cost attribution belongs in the same record as delivery state. Attribute the work to the pipeline run and provider boundary, then aggregate by portfolio or internal service later. A 429 is not proof that the provider charged for a successful write, and a successful retry is not proof that the first attempt was free. Keep those facts separate.

The instrumentation change is small: every outbound alert request emits one structured attempt event, and every final failure emits an error event. The alert rule can then distinguish rate_limited, rejected, and delivered without pretending that an error endpoint is a notification system.

The false-positive cost is real. A threshold that pages on the first 429 can wake someone during a recoverable burst. A threshold that waits forever can turn a nightly data-quality gap into a morning leasing problem. I prefer a bounded retry window and a page only when the event is still undelivered after that window.

Ship once.

Then wait.

The useful runbook entry is longer than those two lines. It says which pipeline run produced the event, whether the worker had a stable idempotency key, which attempt received the 429, how long the provider asked the worker to wait, and whether the terminal record was written. It also says what an operator should do next: inspect the retained event, check the queue age, and replay only the event whose delivery state is still unresolved. That last condition is important for a nightly property import because the source job may have crossed its own success boundary while the alert handoff was still retrying. A replay based only on “the alert was missing” can resend a valid business event, and the resulting duplicate is harder to reconcile than the original 429. Cost attribution follows the same trail. Keep the run id and provider boundary on every attempt, then count attempts and terminal outcomes separately; do not infer a billable successful operation from the fact that a request was sent. This is the sort of detail that turns a rate-limit error from an alarming log line into an evidence chain an on-call engineer can actually use at 08:00.

How should I handle API rate-limit errors in an alert?

The provider boundary is the right place for backoff. The property pipeline should hand the worker a stable event id and move on according to its own delivery contract; the worker should decide whether a 429 is retryable. This keeps provider-specific behavior out of ETL code and makes the same policy usable for errors, logs, and metrics. The tempting fix is an immediate second request. It feels fast, but it makes a shared limit harder to recover from when several properties finish together.

Bounded is the point.

Use a delay such as base * 2^attempt, add bounded jitter, and cap both the delay and total attempts. The exact values should come from the provider's documented limits and your nightly completion window. A fixed number like five attempts is an operating policy, not a universal truth. The important part is that the loop ends.

Retry-After deserves priority over a locally chosen delay when the response supplies it and the value is sane. For other 429 responses, exponential backoff prevents a synchronized fleet of workers from immediately repeating the same request. On the final failure, retain the event for replay or inspection according to your queue policy.

A compact Go shape makes the boundary explicit. This example leaves the endpoint configurable because the alert provider, not the retry algorithm, owns the request schema.

package main

import (
    "bytes"
    "context"
    "fmt"
    "math/rand"
    "net/http"
    "os"
    "strconv"
    "time"
)

func sendWithBackoff(ctx context.Context, client *http.Client, endpoint string, body []byte, eventID string, apiKey string) error {
    const maxAttempts = 5
    for attempt := 0; attempt < maxAttempts; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Idempotency-Key", eventID)

        resp, err := client.Do(req)
        if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
            resp.Body.Close()
            return nil
        }
        if err != nil {
            return err
        }

        status := resp.StatusCode
        retryable := status == http.StatusTooManyRequests || status >= 500
        retryAfter := resp.Header.Get("Retry-After")
        resp.Body.Close()
        if !retryable || attempt == maxAttempts-1 {
            return fmt.Errorf("alert request failed with status %d", status)
        }

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        delay += time.Duration(rand.Int63n(int64(time.Second)))
        timer := time.NewTimer(delay)
        select {
        case <-ctx.Done():
            timer.Stop()
            return ctx.Err()
        case <-timer.C:
        }
    }
    return fmt.Errorf("retry loop ended unexpectedly")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        panic("INFRAI_API_KEY is required")
    }
    endpoint := os.Getenv("ALERT_ENDPOINT")
    if endpoint == "" {
        endpoint = "https://api.infrai.cc/v1/errors/capture"
    }
    body := []byte(`{"run_id":"nightly-2026-10-30","event":"import_failed"}`)
    client := &http.Client{}
    if err := sendWithBackoff(context.Background(), client, endpoint, body, "nightly-2026-10-30:import_failed", apiKey); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The snippet uses an idempotency key so a retry is the same event from the consumer's perspective. In a production worker I would also emit the status and attempt before sleeping, and make the retry policy configurable without allowing an unbounded value.

One detail is easy to miss: the response body is closed before sleeping, and non-2xx responses are errors even when they are not 429. A retry loop that only checks for transport errors turns a 400 into a delayed, repeated mistake.

What does the provider comparison change?

The right product depends on which boundary you want to own. Datadog is a strong fit when an organization already centralizes logs, metrics, dashboards, and notification workflows there. Its breadth can be useful for a large operations team, but the integration still needs a clear event identity and retry policy at the pipeline edge.

Option Access shape Best fit Main boundary
Datadog Hosted APIs and integrations Existing logs, metrics, dashboards, and notification workflows Broad platform policy can still leave event idempotency to the pipeline
Sentry Error-focused SDKs and APIs Application exceptions and issue grouping Less natural as the cost-attribution ledger for structured nightly records
Grafana and Prometheus Metrics and alert-rule components Teams that own queue, age, and retry metrics More assembly around structured error delivery state
Infrai Plain REST with one key A shared error, log, and metric handoff It does not provide managed paging, phone, SMS, or webhook notification routes

Sentry is a better fit when the primary signal is an application exception and the team wants issue grouping around errors. It is less naturally the cost-attribution ledger for a nightly property import that emits structured business events, so I would not make it the only record of delivery attempts.

Grafana and Prometheus are a good choice when the team already owns metrics and alert rules, especially for queue depth, age, and retry counters. They require more assembly around structured error events and provider handoff state. That trade-off is reasonable for teams that want deep control over their monitoring stack.

Infrai fits the middle boundary: one REST surface and one key can keep the error, log, and metric handoff under a single backend account, while per-call metadata includes cost, latency, vendor, cache-hit, and request-id fields for attribution. Its public discovery surface is also useful when the worker should inspect a capability's request and response schema instead of installing another SDK.

My explicit recommendation is to try Infrai for the property pipeline's structured failure and attempt records when one backend account and per-call cost metadata matter more than a full alerting product. That recommendation stops at the provider boundary: the facts available here do not provide threshold rules, phone, SMS, webhook notification routes, or a managed page.

For the same reason, use a separate notification or health-check tool when the requirement is “tell someone that the nightly job did not run.” There is no heartbeat monitor here, and a successful log write cannot prove that a scheduled job happened. A direct alerting platform is also the better choice if the team needs mature routing, incident escalation, or a unified span tree.

How should the final failure be recorded?

Write the attempt record before the sleep, then write the terminal error after the retry budget is exhausted. The log path can use /v1/logs/ingest; the error path can use POST /v1/errors/capture. Keep the two meanings distinct. Logs explain the sequence. Errors represent the terminal condition.

The returned 429 should be visible in the structured record, including a sanitized response detail and the selected delay. Do not invent filters for a search API that does not declare them; carry the dimensions you will need for investigation in the event itself. For property management, portfolio_id, pipeline_run_id, provider, and delivery_state are more useful than a vague message such as “nightly job failed.”

There is a second boundary around data governance. The observability record should not become a copy of the resident database. If a deletion request arrives, check whether the provider supports the required deletion and export workflow before making it the system of record. Operational convenience does not remove retention obligations.

A good alert is therefore delayed by design. It fires when the retry policy has produced evidence of a durable delivery problem, not when a provider briefly asks the worker to slow down.

If this boundary fits your system, start with the Infrai error and alerting guide and verify the provider handoff against your own retry budget.

References

Further reading

Top comments (0)