Ingest telemetry
A production-shaped ingest loop for POST /telemetry. The contract it respects:
- The body is a bare JSON array of records; at most 1,000 records per batch (
413 batch_too_largebeyond that) and at most 1 MB per body. - A
202acknowledgment reportsaccepted_count,rejected_count, andrejected_indices. Accepted rows are already written, so on partial rejection the loop repairs only the rejected records and never resubmits the whole batch. 429and503responses were rejected or failed cleanly and are retried afterRetry-After. A401stops immediately (retrying a bad key trips the auth lockout).- Timeouts and dropped connections are ambiguous: the batch may have landed, and ingest has no dedup, so these loops surface the error instead of blind-retrying. See the idempotency warning.
Set CONSTELLATION_API_BASE and CONSTELLATION_API_TOKEN first (common setup).
- TypeScript
- Python
- Go
- cURL
const BASE = process.env.CONSTELLATION_API_BASE ?? 'https://api.constellation.space'
const TOKEN = process.env.CONSTELLATION_API_TOKEN
if (!TOKEN) throw new Error('CONSTELLATION_API_TOKEN is not set')
const BATCH_MAX = 1000
type TelemetryRecord = {
measurement: string
time: string
tags?: Record<string, string>
fields?: Record<string, number | string>
}
type Ack = {
write_id: string
accepted_count: number
rejected_count: number
rejected_indices: number[] | null
}
async function postBatch(records: TelemetryRecord[], attempt = 0): Promise<Ack> {
const res = await fetch(`${BASE}/telemetry`, {
method: 'POST',
headers: { 'x-api-key': TOKEN!, 'content-type': 'application/json' },
body: JSON.stringify(records),
})
if (res.status === 429 || res.status === 503) {
if (attempt >= 5) throw new Error(`giving up after ${attempt} retries (${res.status})`)
const wait = Number(res.headers.get('retry-after') ?? '5')
await new Promise((r) => setTimeout(r, wait * 1000))
return postBatch(records, attempt + 1)
}
if (res.status === 401) throw new Error('API key rejected (401); stop before tripping lockout')
if (res.status !== 202) throw new Error(`telemetry write failed: ${res.status} ${await res.text()}`)
return (await res.json()) as Ack
}
async function ingest(records: TelemetryRecord[]) {
for (let i = 0; i < records.length; i += BATCH_MAX) {
const batch = records.slice(i, i + BATCH_MAX)
const ack = await postBatch(batch)
console.log(`write ${ack.write_id}: accepted ${ack.accepted_count}, rejected ${ack.rejected_count}`)
for (const idx of ack.rejected_indices ?? []) {
// Accepted rows are already written; repair and resubmit only these.
console.error(`rejected record ${idx}:`, JSON.stringify(batch[idx]))
}
}
}
const now = new Date().toISOString()
await ingest([
{
measurement: 'satellite',
time: now,
tags: { node_id: 'SAT-0012', node_type: 'satellite' },
fields: { lat: 47.61, lon: -122.33, altitude_km: 550.2, utilization: 0.42 },
},
{
measurement: 'link',
time: now,
tags: { link_id: 'SAT-0012:GS-SEA-01', node_id: 'SAT-0012' },
fields: { snr_db: 12.4, utilization: 0.55 },
},
])
import os
import sys
import time
import requests
BASE = os.environ.get("CONSTELLATION_API_BASE", "https://api.constellation.space")
TOKEN = os.environ["CONSTELLATION_API_TOKEN"]
BATCH_MAX = 1000
HEADERS = {"x-api-key": TOKEN, "content-type": "application/json"}
def post_batch(records, attempt=0):
resp = requests.post(f"{BASE}/telemetry", json=records, headers=HEADERS, timeout=30)
if resp.status_code in (429, 503):
if attempt >= 5:
raise RuntimeError(f"giving up after {attempt} retries ({resp.status_code})")
time.sleep(float(resp.headers.get("Retry-After", "5")))
return post_batch(records, attempt + 1)
if resp.status_code == 401:
sys.exit("API key rejected (401); stop before tripping lockout")
if resp.status_code != 202:
raise RuntimeError(f"telemetry write failed: {resp.status_code} {resp.text}")
return resp.json()
def ingest(records):
for start in range(0, len(records), BATCH_MAX):
batch = records[start : start + BATCH_MAX]
ack = post_batch(batch)
print(f"write {ack['write_id']}: accepted {ack['accepted_count']}, "
f"rejected {ack['rejected_count']}")
for idx in ack.get("rejected_indices") or []:
# Accepted rows are already written; repair and resubmit only these.
print(f"rejected record {idx}: {batch[idx]}", file=sys.stderr)
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
ingest([
{
"measurement": "satellite",
"time": now,
"tags": {"node_id": "SAT-0012", "node_type": "satellite"},
"fields": {"lat": 47.61, "lon": -122.33, "altitude_km": 550.2, "utilization": 0.42},
},
{
"measurement": "link",
"time": now,
"tags": {"link_id": "SAT-0012:GS-SEA-01", "node_id": "SAT-0012"},
"fields": {"snr_db": 12.4, "utilization": 0.55},
},
])
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
)
const batchMax = 1000
type Record struct {
Measurement string `json:"measurement"`
Time string `json:"time"`
Tags map[string]string `json:"tags,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
}
type Ack struct {
WriteID string `json:"write_id"`
AcceptedCount int `json:"accepted_count"`
RejectedCount int `json:"rejected_count"`
RejectedIndices []int `json:"rejected_indices"`
}
func postBatch(base, token string, records []Record) (*Ack, error) {
body, err := json.Marshal(records)
if err != nil {
return nil, err
}
for attempt := 0; ; attempt++ {
req, err := http.NewRequest(http.MethodPost, base+"/telemetry", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("x-api-key", token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Ambiguous failure: the batch may have landed. Reconcile, do not blind-retry.
return nil, err
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable {
resp.Body.Close()
if attempt >= 5 {
return nil, fmt.Errorf("giving up after %d retries (%d)", attempt, resp.StatusCode)
}
wait, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
if wait <= 0 {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("API key rejected (401); stop before tripping lockout")
}
if resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("telemetry write failed: %d", resp.StatusCode)
}
var ack Ack
if err := json.NewDecoder(resp.Body).Decode(&ack); err != nil {
return nil, err
}
return &ack, nil
}
}
func main() {
base := os.Getenv("CONSTELLATION_API_BASE")
if base == "" {
base = "https://api.constellation.space"
}
token := os.Getenv("CONSTELLATION_API_TOKEN")
if token == "" {
log.Fatal("CONSTELLATION_API_TOKEN is not set")
}
now := time.Now().UTC().Format(time.RFC3339)
records := []Record{
{
Measurement: "satellite",
Time: now,
Tags: map[string]string{"node_id": "SAT-0012", "node_type": "satellite"},
Fields: map[string]any{"lat": 47.61, "lon": -122.33, "altitude_km": 550.2, "utilization": 0.42},
},
{
Measurement: "link",
Time: now,
Tags: map[string]string{"link_id": "SAT-0012:GS-SEA-01", "node_id": "SAT-0012"},
Fields: map[string]any{"snr_db": 12.4, "utilization": 0.55},
},
}
for start := 0; start < len(records); start += batchMax {
end := min(start+batchMax, len(records))
batch := records[start:end]
ack, err := postBatch(base, token, batch)
if err != nil {
log.Fatal(err)
}
fmt.Printf("write %s: accepted %d, rejected %d\n", ack.WriteID, ack.AcceptedCount, ack.RejectedCount)
for _, idx := range ack.RejectedIndices {
// Accepted rows are already written; repair and resubmit only these.
b, _ := json.Marshal(batch[idx])
fmt.Fprintf(os.Stderr, "rejected record %d: %s\n", idx, b)
}
}
}
#!/usr/bin/env bash
set -euo pipefail
BASE="${CONSTELLATION_API_BASE:-https://api.constellation.space}"
: "${CONSTELLATION_API_TOKEN:?set CONSTELLATION_API_TOKEN}"
RECORDS_FILE="${1:-records.json}" # a JSON array of telemetry records
BATCH_MAX=1000
total=$(jq 'length' "$RECORDS_FILE")
start=0
while ((start < total)); do
batch=$(jq -c ".[${start}:$((start + BATCH_MAX))]" "$RECORDS_FILE")
response=$(curl -sS -w '\n%{http_code}' -X POST \
-H "x-api-key: $CONSTELLATION_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$batch" \
"$BASE/telemetry")
status=$(tail -n1 <<<"$response")
body=$(sed '$d' <<<"$response")
if [[ "$status" == "429" || "$status" == "503" ]]; then
wait=$(jq -r '.retry_after_seconds // 5' <<<"$body")
echo "throttled ($status), waiting ${wait}s" >&2
sleep "$wait"
continue # retry the same batch
fi
if [[ "$status" == "401" ]]; then
echo "API key rejected (401); stop before tripping lockout" >&2
exit 1
fi
if [[ "$status" != "202" ]]; then
echo "write failed ($status): $body" >&2
exit 1
fi
jq '{write_id, accepted_count, rejected_count}' <<<"$body"
# Accepted rows are already written; print rejects for repair, never resubmit the batch.
jq -r --argjson b "$batch" \
'.rejected_indices // [] | .[] | "rejected record \(.): \($b[.])"' <<<"$body" >&2
start=$((start + BATCH_MAX))
done
Example records.json:
[
{
"measurement": "satellite",
"time": "2026-07-09T14:31:42Z",
"tags": { "node_id": "SAT-0012", "node_type": "satellite" },
"fields": { "lat": 47.61, "lon": -122.33, "altitude_km": 550.2, "utilization": 0.42 }
},
{
"measurement": "link",
"time": "2026-07-09T14:31:42Z",
"tags": { "link_id": "SAT-0012:GS-SEA-01", "node_id": "SAT-0012" },
"fields": { "snr_db": 12.4, "utilization": 0.55 }
}
]
Expected output for a clean write:
write w-8c41f2ae: accepted 2, rejected 0
Related
- Telemetry API reference: record schema, tenant stamping, limits.
- Poll topology: read back what you just wrote.
- Integration patterns: flush cadence and rate budgeting.