Go
No dependencies — net/http and encoding/json.
The client
package unzoi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
const Base = "https://api.unzoi.com"
// ErrOutOfCredits is returned when a key that stops at its monthly credits
// cannot cover a call. Unlike the per-minute limit it shares a status code with,
// retrying does not help until Reset elapses.
type ErrOutOfCredits struct{ Reset time.Duration }
func (e *ErrOutOfCredits) Error() string {
return fmt.Sprintf("out of monthly credits; resets in %s", e.Reset)
}
type Client struct {
Key string
HTTP *http.Client
}
func New(key string) *Client {
return &Client{Key: key, HTTP: &http.Client{Timeout: 30 * time.Second}}
}
func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error {
endpoint := Base + path
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
for attempt := 0; attempt < 6; attempt++ {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
request.Header.Set("x-api-key", c.Key)
response, err := c.HTTP.Do(request)
if err != nil {
return err
}
switch {
case response.StatusCode == http.StatusTooManyRequests:
// Three conditions share this status, and the body's code says which.
var refusal map[string]any
_ = json.NewDecoder(response.Body).Decode(&refusal)
response.Body.Close()
if refusal["code"] == "insufficient_credits" {
secs, _ := strconv.Atoi(response.Header.Get("retry-after"))
return &ErrOutOfCredits{Reset: time.Duration(secs) * time.Second}
}
// retry-after is when this call's credits refill, and it is accurate:
// backing off exponentially wastes allowance.
secs, _ := strconv.Atoi(response.Header.Get("retry-after"))
if secs == 0 {
secs = 1
}
select {
case <-time.After(time.Duration(secs) * time.Second):
case <-ctx.Done():
return ctx.Err()
}
case response.StatusCode == http.StatusServiceUnavailable && attempt < 3:
response.Body.Close()
// No shard answered. A narrower time range makes this likelier to
// succeed than a longer wait does.
select {
case <-time.After(time.Duration(1<<attempt) * time.Second):
case <-ctx.Done():
return ctx.Err()
}
case response.StatusCode >= 400:
defer response.Body.Close()
return fmt.Errorf("unzoi: %s", response.Status)
default:
defer response.Body.Close()
return json.NewDecoder(response.Body).Decode(out)
}
}
return errors.New("unzoi: giving up after repeated rate limiting")
} Types and methods
type Article struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Source string `json:"source"`
Language string `json:"language"`
PublishedAt string `json:"published_at"`
StoryID string `json:"story_id"`
Topics []string `json:"topics"`
Organizations []string `json:"organizations"`
Countries []string `json:"countries"`
}
type Story struct {
StoryID string `json:"story_id"`
Count int `json:"count"`
Outlets int `json:"outlets"`
Title string `json:"title"`
URL string `json:"url"`
Sources []string `json:"sources"`
}
// HistoryDays and FromClamped are on every authenticated response: without them
// a plan boundary is indistinguishable from a corpus with no coverage.
type SearchResponse struct {
Total int `json:"total"`
TotalRelation string `json:"total_relation"`
HasMore bool `json:"has_more"`
Results []Article `json:"results"`
HistoryDays *int `json:"history_days"`
FromClamped bool `json:"from_clamped"`
}
type StoriesResponse struct {
Total int `json:"total"`
HasMore bool `json:"has_more"`
Stories []Story `json:"stories"`
HistoryDays *int `json:"history_days"`
FromClamped bool `json:"from_clamped"`
}
func (c *Client) Search(ctx context.Context, params url.Values) (*SearchResponse, error) {
var out SearchResponse
return &out, c.get(ctx, "/search", params, &out)
}
func (c *Client) Stories(ctx context.Context, params url.Values) (*StoriesResponse, error) {
var out StoriesResponse
return &out, c.get(ctx, "/stories", params, &out)
} Using it
client := unzoi.New(os.Getenv("UNZOI_KEY"))
result, err := client.Stories(context.Background(), url.Values{
"q": {"semiconductor export controls"},
"from": {"2026-08-01"},
"limit": {"10"},
})
if err != nil {
log.Fatal(err)
}
if result.FromClamped {
log.Printf("window narrowed to the last %d days by your plan", *result.HistoryDays)
}
for _, story := range result.Stories {
fmt.Printf("%3d articles / %2d outlets %s\n", story.Count, story.Outlets, story.Title)
} Generating instead
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest \
-generate types,client -package unzoi https://api.unzoi.com/openapi.json > unzoi.go Worth it if you want every field typed. The generated client will not have the retry logic above, though — that is the part that decides whether your integration survives a busy minute.
Why a typed error instead of a wrapped one
ErrOutOfCredits is a concrete type rather than fmt.Errorf("out of credits: %w", err)
wrapping a generic one, because the caller's decision is not "did this fail" but "is this worth retrying at all" —
and that answer depends on which of three conditions produced the same 429. A caller that only checks
errors.Is(err, someSentinel) against a shared sentinel cannot recover the reset duration; a caller doing
errors.As(err, &ErrOutOfCredits) gets it directly, typed, without parsing a string. The rate-limit
branch deliberately has no equivalent type: it already resolved itself before returning, so there is nothing left
for the caller to inspect.
Testing the retry logic without the real API
httptest.NewServer stands in for Base in a test binary — point Client.HTTP's
base at the test server's URL and have the handler return 429 with
a body of {"code":"insufficient_credits"} on the first call to assert ErrOutOfCredits
is returned rather than a retry. A second test with "code":"credit_rate_limited" and retry-after: 0 should return
within roughly a second, not immediately and not after an exponential wait — asserting the wall-clock bound is what
catches a regression to backoff-on-every-429, which is the single most common bug this client type gets rewritten
to. A third test returning 503 twice then 200 confirms the narrowing-range comment in the
code is actually followed by a caller, since the client itself cannot narrow anything — it only waits.
Context is not decoration
Every retry sleep above selects on ctx.Done() alongside the timer, which is the part most hand-rolled
Go clients skip. Without it, a caller that cancels a request — a request-scoped timeout in an HTTP handler, a
shutdown signal — waits out the full backoff anyway, because time.Sleep cannot be interrupted.
Threading context.Context through get rather than adding a package-level timeout means a
server embedding this client can cancel one slow in-flight call without affecting any other request it is serving.
Bounding your own concurrency
Nothing in Client stops you from firing twenty goroutines at it simultaneously, and that is exactly
the mistake that turns a working integration into one that spends its first minute almost entirely on 429
responses. A buffered channel used as a semaphore keeps this honest without adding a dependency:
sem := make(chan struct{}, 4) // four requests in flight at once
var wg sync.WaitGroup
for _, topic := range topics {
wg.Add(1)
sem <- struct{}{}
go func(topic string) {
defer wg.Done()
defer func() { <-sem }()
result, err := client.Stories(ctx, url.Values{"q": {topic}, "limit": {"5"}})
// handle result, err
}(topic)
}
wg.Wait()
Four is a starting point, not a tuned constant — a free-tier key's credits a minute divided by what each
call costs, and by however many other calls the same process makes, is the real ceiling;
x-credits-per-minute-remaining on any response tells you
how close to it you already are. Raising the semaphore size past that just moves the wait from your own code into
the retry loop inside get, which handles it correctly but more slowly than not overshooting in the
first place.
Wrap each goroutine's context with its own context.WithTimeout rather than sizing the semaphore down
to compensate for a slow call — the two problems are independent, and conflating them makes the concurrency limit
lie about how many requests are genuinely in flight at once.