For production. Each loop builds the body once, so every retry carries the same insert_ids and nothing is counted twice. It backs off exponentially with jitter (up to 30 seconds), honours Retry-After (up to 5 minutes), gives up after 6 retries, and never retries a request the API refused on its merits.
send.py
import os, random, time, uuid, requests
defsend(items, attempts=6):
body = {"items": items} # each item already has its insert_idfor attempt inrange(attempts + 1):
try:
resp = requests.post(
"https://app.clickclacks.io/api/v1/batch",
headers={"Authorization": f"Bearer {os.environ['CLICKCLACKS_SERVER_KEY']}"},
json=body, timeout=10,
)
except requests.RequestException:
resp = Noneif resp isnotNoneand resp.status_code < 500and resp.status_code notin (408, 429):
resp.raise_for_status() # other 4xx: fix the request, don't retryreturn resp.json() # 202: check resp.json()["errors"] for refused itemsif attempt == attempts:
raiseRuntimeError("ClickClacks: gave up after retries")
retry_after = resp.headers.get("Retry-After") if resp isnotNoneelseNone
delay = float(retry_after) if retry_after else random.uniform(0, min(30, 0.5 * 2 ** attempt))
time.sleep(min(delay, 300))
send([{"event": "Invoice paid", "distinct_id": "user_8412",
"insert_id": uuid.uuid4().hex, "properties": {"amount_cents": 4900}}])
send.go
funcsend(body []byte) error {
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; ; attempt++ {
req, _ := http.NewRequest("POST", "https://app.clickclacks.io/api/v1/batch", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("CLICKCLACKS_SERVER_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
retry := err != nilvar wait time.Duration
iferr == nil {
resp.Body.Close() // for a 202, decode it first and check "errors"
switch {
case resp.StatusCode < 300:
returnnil
case resp.StatusCode == 408 || resp.StatusCode == 429 || resp.StatusCode >= 500:
retry = trueif s, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil {
wait = time.Duration(s) * time.Second
}
default:
return fmt.Errorf("clickclacks: HTTP %d, not retrying", resp.StatusCode)
}
}
if !retry || attempt == 6 {
return fmt.Errorf("clickclacks: gave up after %d attempts", attempt+1)
}
if wait == 0 {
ceiling := math.Min(30, 0.5*math.Pow(2, float64(attempt)))
wait = time.Duration(rand.Float64() * ceiling * float64(time.Second))
}
time.Sleep(min(wait, 5*time.Minute))
}
}
send.rb
require"json"require"net/http"require"securerandom"BATCH_URI = URI("https://app.clickclacks.io/api/v1/batch")
def send_batch(items, attempts: 6)
body = JSON.generate(items: items) # each item already has its insert_id
(0..attempts).each do |attempt|
response = beginNet::HTTP.post(BATCH_URI, body,
"Authorization" => "Bearer #{ENV.fetch("CLICKCLACKS_SERVER_KEY")}",
"Content-Type" => "application/json")
rescueStandardErrornil# network error: retryendif response
status = response.code.to_i
returnJSON.parse(response.body) if status < 300# 202: check "errors" for refused itemsraise"ClickClacks: HTTP #{status}, not retrying"unless status == 408 || status == 429 || status >= 500endraise"ClickClacks: gave up after retries"if attempt == attempts
retry_after = response && response["Retry-After"]
delay = retry_after ? retry_after.to_f : rand * [30, 0.5 * 2**attempt].min
sleep [delay, 300].min
endend
send_batch([{ event: "Invoice paid", distinct_id: "user_8412",
insert_id: SecureRandom.hex(16), properties: { amount_cents: 4900 } }])
A 202 can still list refused items in errors. Log them with their index and code; retrying them unchanged won’t help.
Try it without storing
Add ?validate=true and the API answers 200 with each item exactly as it would be stored, plus every error, and stores nothing. Handy while you write the integration, and in CI. ?strict=true refuses the whole batch if any item is invalid.
Batch up to 500 items per request: the per-project rate limits count events in blocks of 100, so single-event requests run out far sooner. Compress big bodies with gzip; the 1 MiB limit applies after decompression.