Browse documentation
Go
net/http and encoding/json. No dependencies.
Types
package unlob
type Hit struct {
ID string `json:"id"`
URL string `json:"url"`
Host string `json:"host"`
Title string `json:"title"`
Vertical string `json:"vertical"`
// The passage text — usually the answer, not a preview of one.
Snippet string `json:"snippet"`
Score float64 `json:"score"`
HostRank float64 `json:"host_rank"`
Quality int64 `json:"quality"`
FetchedAt int64 `json:"fetched_at"`
Source string `json:"source"`
StoryID string `json:"story_id"`
// Omitted when they do not apply. A missing PublishedAt means the page
// declared no date — not that it was published at the epoch.
GroupSize *int `json:"group_size,omitempty"`
PublishedAt *int64 `json:"published_at,omitempty"`
IndependentSources *int64 `json:"independent_sources,omitempty"`
Centrality *float64 `json:"centrality,omitempty"`
Topics []string `json:"topics,omitempty"`
}
type SearchResponse struct {
Vertical *string `json:"vertical"`
Routed bool `json:"routed"`
Mode string `json:"mode"`
Total int `json:"total"`
Results []Hit `json:"results"`
// True when part of the corpus was unreachable. The answer is INCOMPLETE,
// not merely short.
Partial bool `json:"partial,omitempty"`
}
Pointers for the optional numerics on purpose: IndependentSources == nil means unknown,
which is a different fact from zero.
The client
package unlob
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"strconv"
"time"
)
var ErrQuotaExhausted = errors.New("unlob: monthly quota exhausted")
type Client struct {
Key string
Base string
HTTP *http.Client
}
func New(key string) *Client {
return &Client{
Key: key,
Base: "https://api.unlob.com",
HTTP: &http.Client{Timeout: 20 * time.Second},
}
}
func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path+"?"+q.Encode(), nil)
if err != nil {
return err
}
req.Header.Set("x-api-key", c.Key)
resp, err := c.HTTP.Do(req)
if err != nil {
if attempt < 3 {
sleep(ctx, attempt)
continue
}
return err
}
if resp.StatusCode == http.StatusTooManyRequests {
after := resp.Header.Get("retry-after")
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if after == "" {
// No retry-after means a hard-capped monthly quota, not a rate
// limit. Retrying is a loop that cannot succeed.
return fmt.Errorf("%w: %s", ErrQuotaExhausted, body)
}
secs, _ := strconv.Atoi(after)
select {
case <-time.After(time.Duration(secs) * time.Second):
case <-ctx.Done():
return ctx.Err()
}
continue
}
if (resp.StatusCode >= 500 || resp.StatusCode == 408) && attempt < 3 {
resp.Body.Close()
sleep(ctx, attempt)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unlob %d: %s", resp.StatusCode, body)
}
return json.NewDecoder(resp.Body).Decode(out)
}
return errors.New("unlob: exhausted retries")
}
// Full jitter. Without it, every client that failed together retries together
// and re-creates the spike that broke the thing they are waiting for.
func sleep(ctx context.Context, attempt int) {
d := time.Duration(rand.Float64() * float64(int64(time.Second)<<attempt))
select {
case <-time.After(d):
case <-ctx.Done():
}
}
func (c *Client) Search(ctx context.Context, q url.Values) (*SearchResponse, error) {
var out SearchResponse
if err := c.get(ctx, "/search", q, &out); err != nil {
return nil, err
}
return &out, nil
}
Using it
func main() {
c := unlob.New(os.Getenv("UNLOB_API_KEY"))
q := url.Values{}
q.Set("q", "how does tokio schedule tasks")
q.Set("vertical", "code")
q.Set("collapse", "story")
q.Set("limit", "5")
body, err := c.Search(context.Background(), q)
if errors.Is(err, unlob.ErrQuotaExhausted) {
log.Fatal("out of quota for this period")
} else if err != nil {
log.Fatal(err)
}
if body.Partial {
// A 200 that means the answer is incomplete.
log.Println("WARNING: incomplete — part of the corpus was unreachable")
}
for _, h := range body.Results {
fmt.Printf("%s — %s\n%s\n\n", h.Title, h.URL, h.Snippet)
}
}
Filters
url.Values carries them all. IN-lists are comma-separated:
q.Set("tld", "gov,edu")
q.Set("min_independent_sources", "2")
q.Set("min_host_rank", "0.6")
q.Set("sort", "centrality")