Skip to content
unzoi docs
Search and navigation
Start here
REST API
MCP
Limits and plans
Agent clients
SDKs
Guides

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.