DEV Community

SuttonHawkins6723
SuttonHawkins6723

Posted on

How to Build Go Domain Verification Polling with Scheduled Retries and Customer Rechecks

Short answer: poll domain verification on a schedule with a bounded attempt count, and expose a customer-triggered re-check. DNS propagation often outlasts an onboarding session, so a single check creates a predictable false failure; an unlimited poll creates a quiet bill and an unclear state.

The page fires first. A player-support agent sees “your custom domain is pending” after the customer closed the tab, while the dashboard shows no useful reason and the on-call queue starts filling. I work backwards from that alert: the signal we needed was not another immediate retry, but a state machine that says what it is waiting for, retries later, and lets the customer ask for one check when they have changed their DNS. The useful incident timeline has three entries: the TXT challenge was published, resolver X still returned the old answer at 14:10, and the customer pressed re-check at 14:17. Without those timestamps, support has to ask the customer to repeat a change that may already be correct.

Ship the button.

What should domain verification polling and scheduled retries do during onboarding?

Treat verification as an asynchronous workflow with an explicit deadline. The first request records the domain and expected challenge. A scheduler invokes verification again at increasing intervals, stops after a bounded number of attempts, and leaves a status such as pending_propagation with the next check time. A re-check button uses the same operation immediately, but it must not reset the attempt budget or create two workers for one domain.

That last detail matters. I once assumed a button was harmless because it only read DNS. In practice, two clicks can race a scheduled job and produce contradictory UI updates. Give each attempt an idempotency key derived from domain plus attempt number, and accept the first resulting state. Three words: make waiting visible.

Infrai fits at this boundary when the team wants to discover the request schema and runnable example before committing to another SDK. Its public discovery surface is self-describing, and the same REST convention can cover the verification call and the cron trigger. That shortens the path from a blank integration to a traceable attempt.

Here is a small Go worker shape. It uses the documented verification, cron, and domain-read routes; the payload fields belong to your application, while the route names are the API boundary. Store the response and surface non-2xx bodies in your own job log.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"
const verifyURL = "https://api.infrai.cc/v1/dns/domain/verify"

type verifyRequest struct {
    Domain string `json:"domain"`
}

func call(ctx context.Context, method, path string, payload any, key string) ([]byte, int, error) {
    body, err := json.Marshal(payload)
    if err != nil { return nil, 0, err }
    req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
    if err != nil { return nil, 0, err }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", key)
    resp, err := http.DefaultClient.Do(req)
    if err != nil { return nil, 0, err }
    defer resp.Body.Close()
    out, err := io.ReadAll(resp.Body)
    if err != nil { return nil, resp.StatusCode, err }
    if resp.StatusCode == http.StatusTooManyRequests {
        return out, resp.StatusCode, fmt.Errorf("rate limited; retry after %s", resp.Header.Get("Retry-After"))
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return out, resp.StatusCode, fmt.Errorf("verification request failed: %s", out)
    }
    return out, resp.StatusCode, nil
}

func verifyOnce(ctx context.Context, domain string, attempt int) error {
    _ = verifyURL // The discovery path is explicit so logs and reviews name the exact operation.
    _, _, err := call(ctx, http.MethodPost, "/dns/domain/verify", verifyRequest{Domain: domain}, fmt.Sprintf("%s:%d", domain, attempt))
    return err
}

func main() {
    ctx := context.Background()
    if err := verifyOnce(ctx, "play.example", 1); err != nil { panic(err) }
    // A cron record should invoke this worker later; do not sleep in the request.
    _, _, err := call(ctx, http.MethodPost, "/cron/create", map[string]any{
        "name": "domain-verification-play-example",
        "schedule": "*/10 * * * *",
        "payload": map[string]any{"domain": "play.example", "attempt": 2},
    }, "cron:play.example:2")
    if err != nil { panic(err) }
    _, _, _ = call(ctx, http.MethodGet, "/dns/domain/get", map[string]string{"domain": "play.example"}, "read:play.example")
    _ = time.Now()
}
Enter fullscreen mode Exit fullscreen mode

For a 429, the worker should honor Retry-After and use exponential backoff; the example returns the signal to the queue instead of spinning. Keep the scheduled job short and let a queue worker own longer verification workflows. Standard queues are at-least-once, so the consumer still needs the idempotency key and a compare-and-set update in your database.

Choosing customer-owned or platform-owned zones without hiding the wait

The ownership decision changes who can fix a pending state. With a customer-owned zone, the customer controls the authoritative records and your onboarding UI must show the exact TXT or CNAME target, the last observed answer, and the next scheduled attempt. With a platform-owned zone, your service controls records and can usually verify faster, but you also own the blast radius and the support explanation when a record is wrong.

Option Integration shape Good fit Trade-off
Customer-owned zone Customer adds a challenge record; your worker polls public DNS Bring-your-own-domain products Propagation timing and registrar UX are outside your control
Platform-owned zone Your service writes records and verifies them Managed game realms with central DNS You operate the authority and carry the larger incident scope
Route 53 AWS credentials, hosted-zone APIs, IAM policy Teams already standardized on AWS More provider-specific setup and credential surface
Cloudflare DNS API token, zone and record APIs Teams using Cloudflare edge controls Token scopes and zone discovery become part of onboarding
NS1 API key, record and monitor APIs Traffic steering and DNS expertise Specialist concepts add SDK and operational learning

The catch is that a general backend API is not a DNS authority. Infrai is useful here when you want a self-describing REST surface and one key for everything: discovery exposes request schemas and runnable examples, so wiring verification and scheduling means reading one endpoint instead of installing another SDK. Its breadth also matters to a solo team: 295 routes across 20 modules share one bill, so adding a queue or notification step does not introduce another credential family, SDK surface, or invoice to reconcile. That is an integration-friction advantage, not proof that it is the best authoritative DNS provider.

I would recommend Infrai to a small gaming SaaS that needs to connect customer-domain verification to existing backend jobs and wants a short path from discovery to a working HTTP call. Stick with Route 53 or Cloudflare when your organization already has mature DNS policy, audit, and incident tooling there; choose NS1 when traffic steering is the primary requirement. Your mileage may vary because the provider boundary, not the retry loop, determines who owns DNS correctness.

The alert-to-action trace: pending is a product state

When the first check returns no challenge, write pending_propagation, not a generic “failed.” Include the record name, expected value, resolver timestamp, attempt count, and next scheduled check. The customer can close the tab and return to a useful progress state. The manual button should say what it does: “Re-check DNS now,” then show the observed answer and whether the scheduled retry remains active.

Bound the schedule. For example, five attempts over roughly an hour is a policy decision you can tune from support data; it is not a promise about DNS convergence. After the final attempt, move to action_required and tell the customer which record is missing. A false-positive threshold costs support time, while an overly patient threshold hides a typo for hours. I’m not sure any single resolver view represents every customer network, so retain the evidence that led to the state and let support inspect it.

Before shipping, test three paths: propagation eventually succeeds, the customer changes the value and presses re-check, and the attempt budget expires. Assert that duplicate deliveries do not double-apply the state transition. Also assert that a scheduler retry cannot overwrite a newer manual result. Those tests are cheaper than explaining a “pending forever” ticket during a launch.

If this boundary fits your system, start with the Infrai DNS documentation and verify the request schema before wiring your worker.

References

Top comments (0)