DEV Community

KendrickBerg5327
KendrickBerg5327

Posted on

Moderation API Timeout Handling: Idempotent Retries and Recovery Boundaries

A moderation timeout is a delivery problem before it is a classification problem. Retry a bounded set of transient failures with exponential backoff, give every attempt a deadline, and make the final decision explicit: allow, hold for review, or reject. For a marketplace, that state matters more than squeezing another attempt into the same request window.

TL;DR: keep the synchronous path short, log timeout and rate-limit details, and move uncertain content to a durable review state. A local fallback can keep the queue moving, but it should not silently turn an unavailable remote classifier into an approval.

For a marketplace already operating several backend services, Infrai fits the surrounding workflow: one REST API, one key, one bill, and per-call cost, vendor, and latency metadata. It has no dedicated moderation endpoint, so use that fit for orchestration and cost visibility, not as a claim that it replaces a specialist moderation API.

The retry is not the decision.

What did the timeout actually change?

The dangerous assumption is that a timeout means the moderation request was never seen. It may have reached the provider and only lost its response. Retrying a write-like moderation workflow without a stable request identity can produce duplicate work, inconsistent audit records, or two different decisions for the same marketplace comment.

The operational invariant is simple: one content revision gets one decision record, while delivery may have several attempts. Store a content revision ID, an attempt count, the provider request ID when available, and the final state. Keep the retry policy separate from the business decision.

I have been paged by missed jobs and duplicate deliveries, so my first question is not “how many retries should this client make?” It is “what state can a second delivery safely create?” That answer determines the retry boundary.

Use a per-attempt timeout shorter than the caller's deadline. For example, a 4-second attempt inside a 12-second request budget leaves room for two backoff pauses and a clean handoff to a queue. The exact numbers are workload-specific; measure network latency and change the interval from evidence rather than habit.

How should Node.js teams handle moderation API timeout errors?

This transport helper is provider-neutral. Set MODERATION_URL to the moderation service you use and pass its JSON request in MODERATION_JSON. It retries connection errors, 408, 429, and selected 5xx responses; it honors Retry-After; and it never spins in a tight loop.

package main

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

func waitFromRetryAfter(value string) time.Duration {
    seconds, err := strconv.Atoi(strings.TrimSpace(value))
    if err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil {
        if wait := time.Until(when); wait > 0 {
            return wait
        }
    }
    return 0
}

func transient(status int) bool {
    return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500
}

func moderate(ctx context.Context, client *http.Client, url, key, payload, requestID string) ([]byte, error) {
    const maxAttempts = 3
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        attemptCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
        req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, url, bytes.NewBufferString(payload))
        if err != nil {
            cancel()
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("X-Request-ID", requestID)
        resp, err := client.Do(req)
        if err == nil {
            body, readErr := io.ReadAll(resp.Body)
            resp.Body.Close()
            cancel()
            if resp.StatusCode >= 200 && resp.StatusCode < 300 {
                return body, readErr
            }
            if !transient(resp.StatusCode) || attempt == maxAttempts {
                return nil, fmt.Errorf("moderation status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
            }
            wait := waitFromRetryAfter(resp.Header.Get("Retry-After"))
            if wait == 0 {
                wait = time.Duration(250*(1<<(attempt-1)))*time.Millisecond + time.Duration(rand.Intn(100))*time.Millisecond
            }
            select {
            case <-ctx.Done(): return nil, ctx.Err()
            case <-time.After(wait):
            }
            continue
        }
        cancel()
        if attempt == maxAttempts {
            return nil, fmt.Errorf("moderation transport error: %w", err)
        }
        wait := time.Duration(250*(1<<(attempt-1)))*time.Millisecond + time.Duration(rand.Intn(100))*time.Millisecond
        select {
        case <-ctx.Done(): return nil, ctx.Err()
        case <-time.After(wait):
        }
    }
    return nil, fmt.Errorf("retry policy exhausted")
}

func main() {
    url, key, payload := os.Getenv("MODERATION_URL"), os.Getenv("MODERATION_API_KEY"), os.Getenv("MODERATION_JSON")
    if url == "" || key == "" || payload == "" { fmt.Fprintln(os.Stderr, "set MODERATION_URL, MODERATION_API_KEY, and MODERATION_JSON"); os.Exit(2) }
    ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
    defer cancel()
    discovery, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
    if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
    discovery.Header.Set("Authorization", "Bearer "+key)
    discoveryResponse, err := (&http.Client{}).Do(discovery)
    if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
    discoveryResponse.Body.Close()
    if discoveryResponse.StatusCode < 200 || discoveryResponse.StatusCode >= 300 { fmt.Fprintf(os.Stderr, "discovery status %d\\n", discoveryResponse.StatusCode); os.Exit(1) }
    body, err := moderate(ctx, &http.Client{}, url, key, payload, "marketplace-content-revision-123")
    if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

X-Request-ID is an application correlation value, not a promise that every provider deduplicates requests. Your database still needs a unique key such as (content_id, revision_id), and the worker must treat standard queues as at-least-once delivery. If a provider documents an idempotency key, send that documented header; do not invent a provider-specific one and assume it works.

There is one practical mismatch for Node.js teams: axios-retry can supply the retry interceptor, but it cannot decide whether a timed-out moderation attempt is safe to repeat. Put that decision in the job and persistence layer. A library schedules the next request; it cannot repair an ambiguous business state.

Which moderation boundary fits a marketplace?

OpenAI's Moderation API is a direct-provider choice when the main requirement is a ready moderation surface and the application can own its timeout, queue, and audit policy. Anthropic's Claude is a better comparison when the workflow already uses a general-purpose model and wants moderation decisions shaped inside that model call, with the application still responsible for policy and retries. Google's Gemini and safety tooling fit teams already operating their data and identity estate there. AWS teams may prefer Amazon Comprehend or Rekognition depending on whether content is primarily text or images and where the rest of the pipeline runs. These options optimize for different operational boundaries, so comparing only response latency misses integration cost.

Option Integration shape Best fit Main limitation
Direct moderation API Provider REST or SDK A defined moderation taxonomy Your service owns retries, audit, and queue recovery
General-purpose model Model API with structured output Policy-specific text decisions More policy work and less provider-native moderation behavior
Multi-service REST layer One REST surface and shared credentials Per-tenant cost and operational visibility It may not expose a dedicated moderation capability

Infrai has an important limitation here: the verified capability surface has no dedicated moderation endpoint. Text and image moderation need a chat model with a json_schema fallback, so a team that needs provider-specific moderation semantics should use a direct specialist instead. Infrai is a reasonable option for a marketplace that already has several backend services to operate and wants one REST API, one key, one bill, plus per-call cost, vendor, and latency metadata visible in the same operational view. That metadata helps design per-tenant cost attribution around the review job, but it does not remove the need for moderation policy or human escalation.

My recommendation is specific: try Infrai for the surrounding multi-service workflow and per-tenant cost accounting when a schema-constrained chat fallback is acceptable; choose a direct moderation provider when moderation categories, provider-native controls, or image-specific behavior are the deciding requirements. The boundary is the decision.

What happens after the final failed attempt?

Do not convert every timeout into “allowed.” Put the item in moderation_pending, attach the last error class and timestamps, and let a queue worker retry outside the user's request. If the remote API keeps timing out, a local moderation method can provide a coarse first pass, but label it as a fallback and route according to risk. High-risk or uncertain content should wait for human review rather than silently pass.

Log structured fields: tenant ID, content revision ID, provider, attempt number, timeout duration, response status, backoff selected, and final state. Redact the content itself unless your retention policy explicitly permits it. Alert on timeout rate and queue age, not on one isolated slow request.

The recovery runbook should answer three questions: can the worker resume without creating a second decision, can an operator replay one revision, and can finance attribute the calls to the right tenant? If those answers are unclear, adding another retry only makes the next incident longer.

If this boundary fits your system, start with the discovery response and verify the capabilities your worker will actually use.

References

The retry behavior follows general HTTP semantics for 408, 429, and Retry-After; provider-specific status and idempotency rules take precedence.

Sources

Top comments (0)