From 21f386a35a6eaeaba0bb01cdae9d06cdc5d28ddc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:22 +0000 Subject: [PATCH 1/5] Report body read errors once The three errors.Wrapf calls that wrap a failure to read the body of an error response also formatted that failure into the message, so the read error appeared twice in Error(). Wrap it once, and add a test that drives the read error with a server declaring a longer Content-Length than it sends. Co-Authored-By: Claude Fable 5.1 --- client.go | 6 ++--- client_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index 985bcd2..bd0bc2f 100644 --- a/client.go +++ b/client.go @@ -163,7 +163,7 @@ func (c Client) RawLookupWithContext(ctx context.Context, ip string) (*http.Resp body, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.Wrapf(err, "failed to read body from response with status code %q: %s", resp.Status, err) + return nil, errors.Wrapf(err, "failed to read body from response with status code %q", resp.Status) } var a apiErr @@ -257,7 +257,7 @@ func (c Client) LookupFieldsWithContext(ctx context.Context, ip string, fields [ default: body, err := io.ReadAll(resp.Body) if err != nil { - return IP{}, errors.Wrapf(err, "failed to read body from response with status code %q: %s", resp.Status, err) + return IP{}, errors.Wrapf(err, "failed to read body from response with status code %q", resp.Status) } var a apiErr @@ -315,7 +315,7 @@ func (c *Client) RawBulkLookupWithContext(ctx context.Context, ips []string) (*h body, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.Wrapf(err, "failed to read body from response with status code %q: %s", resp.Status, err) + return nil, errors.Wrapf(err, "failed to read body from response with status code %q", resp.Status) } var a apiErr diff --git a/client_test.go b/client_test.go index 92c37e3..61ebaf7 100644 --- a/client_test.go +++ b/client_test.go @@ -10,6 +10,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/json" + "errors" "fmt" "io" "net" @@ -2624,3 +2625,74 @@ func TestClient_BulkLookup_newFields(t *testing.T) { t.Fatalf("IP slice differs: (-want +got)\n%s", diff) } } + +// TestClient_truncatedErrorResponse covers an error response whose body cannot +// be read to the end: the server announces more bytes than it sends and then +// closes the connection. The returned error names the status and carries the +// read error exactly once. +func TestClient_truncatedErrorResponse(t *testing.T) { + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "100") + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"message":`) + } + + mux := http.NewServeMux() + mux.HandleFunc("/8.8.8.8", handler) + mux.HandleFunc("/bulk", handler) + + server := httptest.NewServer(mux) + defer server.Close() + + c := Client{c: newHTTPClient(), e: server.URL + "/", k: "testAPIkey"} + + tests := []struct { + name string + call func() error + }{ + { + name: "Lookup", + call: func() error { + _, err := c.Lookup("8.8.8.8") + return err + }, + }, + { + name: "RawLookup", + call: func() error { + _, err := c.RawLookup("8.8.8.8") + return err + }, + }, + { + name: "BulkLookup", + call: func() error { + _, err := c.BulkLookup([]string{"1.1.1.1", "8.8.8.8"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if err == nil { + t.Fatalf("%s() returned no error, want a body read error", tt.name) + } + + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("%s() error = %v, want it to wrap io.ErrUnexpectedEOF", tt.name, err) + } + + msg := err.Error() + + if prefix := `failed to read body from response with status code "500 Internal Server Error": `; !strings.HasPrefix(msg, prefix) { + t.Errorf("%s() error = %q, want prefix %q", tt.name, msg, prefix) + } + + if n := strings.Count(msg, io.ErrUnexpectedEOF.Error()); n != 1 { + t.Errorf("%s() error = %q, mentions the read error %d times, want once", tt.name, msg, n) + } + }) + } +} From 3de291d24e3262862d0c6ab267fa4c664e3d3c38 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:54:33 +0000 Subject: [PATCH 2/5] Send the fields parameter in LookupFields requests LookupFieldsWithContext appended "?fields=..." to the URL, but newGetRequestWithContext then replaced the whole query string with the API key, so the fields never reached the API and every LookupFields call received the full response. newGetRequestWithContext now takes the extra query parameters as a url.Values and encodes them together with the API key, which always takes precedence. LookupFields passes the comma-joined field list that way. The single-IP lookups share one request path (rawLookup, lookup) instead of a second copy of the response handling. Tests assert that the request the server receives carries fields and api-key (and no fields parameter when none were given), that the partial response is decoded, and that newGetRequestWithContext encodes the parameters without modifying the caller's values. Co-Authored-By: Claude Fable 5.1 --- client.go | 86 ++++++++++++++++++++-------------------------- client_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 126 insertions(+), 53 deletions(-) diff --git a/client.go b/client.go index bd0bc2f..7da13f7 100644 --- a/client.go +++ b/client.go @@ -29,9 +29,10 @@ const Version = "0.8.2" const fqpn = "github.com/ipdata/go" const ( - apiEndpoint = "https://api.ipdata.co/" - euAPIEndpoint = "https://eu-api.ipdata.co/" - apiAuthParam = "api-key" + apiEndpoint = "https://api.ipdata.co/" + euAPIEndpoint = "https://eu-api.ipdata.co/" + apiAuthParam = "api-key" + apiFieldsParam = "fields" ) var userAgent = fmt.Sprintf( @@ -140,8 +141,16 @@ func (c Client) RawLookup(ip string) (*http.Response, error) { // RawLookupWithContext is a RawLookup that uses a provided context.Context. func (c Client) RawLookupWithContext(ctx context.Context, ip string) (*http.Response, error) { + return c.rawLookup(ctx, ip, nil) +} + +// rawLookup sends the GET request for ip, with params (which may be nil) and +// the API key as the query string, and returns the response when its status is +// 200 OK. Any other status becomes an error, of type Error when the body holds +// the API's JSON message, and the response body is closed. +func (c Client) rawLookup(ctx context.Context, ip string, params url.Values) (*http.Response, error) { // build request - req, err := newGetRequestWithContext(ctx, c.e+ip, c.k) + req, err := newGetRequestWithContext(ctx, c.e+ip, c.k, params) if err != nil { return nil, errors.Wrapf(err, "error building request to look up %s", ip) } @@ -192,7 +201,7 @@ func decodeIP(r io.Reader) (IP, error) { } // Lookup takes an IP address to look up the details for. An empty string means -// you want the information about the current node's pubilc IP address. If an +// you want the information about the current node's public IP address. If an // API error occurs, the error value will be of type Error. func (c Client) Lookup(ip string) (IP, error) { return c.LookupWithContext(context.Background(), ip) @@ -200,22 +209,7 @@ func (c Client) Lookup(ip string) (IP, error) { // LookupWithContext is a Lookup that uses a provided context.Context. func (c Client) LookupWithContext(ctx context.Context, ip string) (IP, error) { - resp, err := c.RawLookupWithContext(ctx, ip) - if err != nil { - return IP{}, err - } - - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() - - pip, err := decodeIP(resp.Body) - if err != nil { - return IP{}, err - } - - return pip, nil + return c.lookup(ctx, ip, nil) } // LookupFields takes an IP address and a list of fields to return. Only the @@ -227,19 +221,19 @@ func (c Client) LookupFields(ip string, fields []string) (IP, error) { // LookupFieldsWithContext is a LookupFields that uses a provided context.Context. func (c Client) LookupFieldsWithContext(ctx context.Context, ip string, fields []string) (IP, error) { - reqURL := c.e + ip + var params url.Values if len(fields) > 0 { - reqURL += "?fields=" + strings.Join(fields, ",") + params = url.Values{apiFieldsParam: []string{strings.Join(fields, ",")}} } - req, err := newGetRequestWithContext(ctx, reqURL, c.k) - if err != nil { - return IP{}, errors.Wrapf(err, "error building request to look up %s", ip) - } + return c.lookup(ctx, ip, params) +} - resp, err := c.c.Do(req) +// lookup performs rawLookup and decodes the body of the 200 OK response. +func (c Client) lookup(ctx context.Context, ip string, params url.Values) (IP, error) { + resp, err := c.rawLookup(ctx, ip, params) if err != nil { - return IP{}, errors.Wrapf(err, "http request to %q failed", req.URL.Scheme+"://"+req.URL.Host+req.URL.Path) + return IP{}, err } defer func() { @@ -247,28 +241,18 @@ func (c Client) LookupFieldsWithContext(ctx context.Context, ip string, fields [ _ = resp.Body.Close() }() - switch resp.StatusCode { - case http.StatusOK: - pip, err := decodeIP(resp.Body) - if err != nil { - return IP{}, err - } - return pip, nil - default: - body, err := io.ReadAll(resp.Body) - if err != nil { - return IP{}, errors.Wrapf(err, "failed to read body from response with status code %q", resp.Status) - } - - var a apiErr - if err := json.Unmarshal(body, &a); err != nil { - return IP{}, errors.Errorf("request for %q failed (unexpected response): %s: %v", ip, resp.Status, err) - } - return IP{}, newError(a.Message, resp.StatusCode) + pip, err := decodeIP(resp.Body) + if err != nil { + return IP{}, err } + + return pip, nil } -func newGetRequestWithContext(ctx context.Context, urlStr, apiKey string) (*http.Request, error) { +// newGetRequestWithContext builds the GET request for urlStr. Its query string +// is built from params plus the API key, which always takes precedence over a +// value in params; params may be nil and is left unchanged. +func newGetRequestWithContext(ctx context.Context, urlStr, apiKey string, params url.Values) (*http.Request, error) { if len(urlStr) == 0 { return nil, errors.New("url cannot be an empty string") } @@ -285,7 +269,11 @@ func newGetRequestWithContext(ctx context.Context, urlStr, apiKey string) (*http req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/json") - q := url.Values{apiAuthParam: []string{apiKey}} + q := make(url.Values, len(params)+1) + for name, values := range params { + q[name] = values + } + q.Set(apiAuthParam, apiKey) req.URL.RawQuery = q.Encode() return req, nil diff --git a/client_test.go b/client_test.go index 61ebaf7..766bb21 100644 --- a/client_test.go +++ b/client_test.go @@ -586,6 +586,9 @@ func Test_client_Lookup(t *testing.T) { } func Test_client_LookupFields(t *testing.T) { + // query holds the query string of the last request the server received + var query atomic.Pointer[url.Values] + mux := http.NewServeMux() mux.HandleFunc("/76.14.47.42", func(w http.ResponseWriter, r *http.Request) { @@ -594,6 +597,9 @@ func Test_client_LookupFields(t *testing.T) { return } + q := r.URL.Query() + query.Store(&q) + if r.FormValue("api-key") != "testAPIkey" { w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, "API key does not exist.") @@ -619,12 +625,34 @@ func Test_client_LookupFields(t *testing.T) { k: "testAPIkey", } + // lastQuery returns the query string of the last request the server saw + lastQuery := func(t *testing.T) url.Values { + t.Helper() + + q := query.Load() + if q == nil { + t.Fatal("the server received no request") + } + + return *q + } + t.Run("with_fields", func(t *testing.T) { ip, err := c.LookupFields("76.14.47.42", []string{"ip", "country_name"}) if err != nil { t.Fatalf("LookupFields() unexpected error: %v", err) } + q := lastQuery(t) + + if got, want := q.Get("fields"), "ip,country_name"; got != want { + t.Errorf("request query fields = %q, want %q", got, want) + } + + if got, want := q.Get("api-key"), "testAPIkey"; got != want { + t.Errorf("request query api-key = %q, want %q", got, want) + } + if ip.IP != "76.14.47.42" { t.Errorf("ip.IP = %q, want %q", ip.IP, "76.14.47.42") } @@ -632,6 +660,12 @@ func Test_client_LookupFields(t *testing.T) { if ip.CountryName != "United States" { t.Errorf("ip.CountryName = %q, want %q", ip.CountryName, "United States") } + + // the partial response the server sends for a request with fields + // has no city; a city means the full document came back + if ip.City != "" { + t.Errorf("ip.City = %q, want %q (fields were not applied)", ip.City, "") + } }) t.Run("no_fields", func(t *testing.T) { @@ -640,6 +674,16 @@ func Test_client_LookupFields(t *testing.T) { t.Fatalf("LookupFields() unexpected error: %v", err) } + q := lastQuery(t) + + if _, ok := q["fields"]; ok { + t.Errorf("request query fields = %q, want no fields parameter", q.Get("fields")) + } + + if got, want := q.Get("api-key"), "testAPIkey"; got != want { + t.Errorf("request query api-key = %q, want %q", got, want) + } + if ip.IP != "76.14.47.42" { t.Errorf("ip.IP = %q, want %q", ip.IP, "76.14.47.42") } @@ -647,6 +691,10 @@ func Test_client_LookupFields(t *testing.T) { if ip.CountryName != "United States" { t.Errorf("ip.CountryName = %q, want %q", ip.CountryName, "United States") } + + if ip.City != "San Francisco" { + t.Errorf("ip.City = %q, want %q", ip.City, "San Francisco") + } }) } @@ -777,9 +825,10 @@ func mustParseURL(u string) *url.URL { func Test_newGetRequestWithContext(t *testing.T) { tests := []struct { - name string - url string - key string + name string + url string + key string + params url.Values want *http.Request err string @@ -805,11 +854,39 @@ func Test_newGetRequestWithContext(t *testing.T) { URL: mustParseURL("http://localhost/?api-key=abc123"), }, }, + { + name: "url_with_params", + key: "abc123", + url: "http://localhost/", + params: url.Values{"fields": {"ip,country_name"}}, + want: &http.Request{ + Header: map[string][]string{ + "User-Agent": {userAgent}, + "Accept": {"application/json"}, + }, + URL: mustParseURL("http://localhost/?api-key=abc123&fields=ip%2Ccountry_name"), + }, + }, + { + name: "params_do_not_replace_api_key", + key: "abc123", + url: "http://localhost/", + params: url.Values{"api-key": {"other"}, "fields": {"ip"}}, + want: &http.Request{ + Header: map[string][]string{ + "User-Agent": {userAgent}, + "Accept": {"application/json"}, + }, + URL: mustParseURL("http://localhost/?api-key=abc123&fields=ip"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := newGetRequestWithContext(context.Background(), tt.url, tt.key) + before := tt.params.Encode() + + got, err := newGetRequestWithContext(context.Background(), tt.url, tt.key, tt.params) if cont := testErrCheck(t, "newGetRequestWithContext()", tt.err, err); !cont { return } @@ -818,6 +895,14 @@ func Test_newGetRequestWithContext(t *testing.T) { t.Fatalf("got.URL = %q, want %q", gots, wants) } + if gots, wants := got.URL.Query().Get("fields"), tt.params.Get("fields"); gots != wants { + t.Fatalf("fields query parameter = %q, want %q", gots, wants) + } + + if after := tt.params.Encode(); after != before { + t.Fatalf("params = %q after the call, want them unchanged (%q)", after, before) + } + if gots, wants := got.Header.Get("User-Agent"), tt.want.Header.Get("User-Agent"); gots != wants { t.Fatalf("User-Agent = %q, want %q", gots, wants) } From d8257399cfac58ac3a6506ac3ae21357df467efa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:56:09 +0000 Subject: [PATCH 3/5] Add WithTimeout option for an overall request time limit The default *http.Client bounds dialling and the TLS handshake but puts no limit on waiting for or reading the response, so without a context deadline a request can wait indefinitely. WithTimeout(d) sets Timeout on the *http.Client a Client uses. It is applied after all other options, so it also covers an *http.Client given with WithHTTPClient, in either order: that client is copied with the Timeout set and the caller's value is left untouched. Without the option nothing changes. Tests cover both constructors, the option order, the untouched custom client, WithTimeout(0), and lookups against a server that answers too late failing with an error whose Timeout() reports true. The README and the package documentation describe the default and the option. Co-Authored-By: Claude Fable 5.1 --- README.md | 29 +++++++++ client.go | 58 +++++++++++++----- client_test.go | 159 +++++++++++++++++++++++++++++++++++++++++++++++++ doc.go | 4 +- 4 files changed, 233 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3cf2825..fcca2cb 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,35 @@ setup off when an `http.Transport` sets a custom `DialContext` or `TLSClientConfig`, so set `ForceAttemptHTTP2: true` on such a transport (the default transport already does) to keep speaking HTTP/2 to api.ipdata.co. +### Timeouts + +By default the client sets no overall time limit on a request: dialling and the +TLS handshake are bounded (30 and 10 seconds), but waiting for the API's +response and reading it are not. Bound a single call with a context, or give +the client a limit for every request with `WithTimeout`; when both are set, +the earlier deadline wins. + +```go +client, err := ipdata.NewClient("YOUR_API_KEY", ipdata.WithTimeout(10*time.Second)) +if err != nil { + panic(err) +} + +ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) +defer cancel() + +data, err := client.LookupWithContext(ctx, "8.8.8.8") +``` + +The limit covers the whole request, from dialling to the end of reading the +response body, so the body of a `RawLookup` response has to be read within it +as well. A request that exceeds the limit fails with an error whose `Timeout()` +method reports true, and bulk lookups are never replayed after a timeout. +`WithTimeout` also applies to an `*http.Client` passed with `WithHTTPClient`, in +whichever order the options are given: the client then uses a copy of it with +`Timeout` set and leaves the caller's value untouched. `WithTimeout(0)` means +no limit. + ### EU Endpoint (GDPR Compliance) For GDPR compliance, use `NewEUClient` to route all requests through EU data diff --git a/client.go b/client.go index 7da13f7..5012ce1 100644 --- a/client.go +++ b/client.go @@ -54,6 +54,27 @@ func WithHTTPClient(httpClient *http.Client) Option { } } +// WithTimeout sets a time limit for every request made through the Client, by +// setting Timeout on the *http.Client it uses. The limit covers the whole +// request, from dialling the connection to the end of reading the response +// body, including the body of a response returned by RawLookup or +// RawBulkLookup. Without this option no overall limit is set and a request is +// bounded only by the context passed to the WithContext methods; a context +// deadline and the time limit combine, and the earlier one wins. A request +// that exceeds the limit fails with an error whose Timeout method reports +// true, and a bulk lookup is never replayed after a timeout. +// +// The limit is applied once all options have been processed, so it also +// covers an *http.Client supplied with WithHTTPClient, whatever the order of +// the options: the Client then uses a copy of that *http.Client with Timeout +// set, and the caller's value is left untouched. A zero d means no limit. +func WithTimeout(d time.Duration) Option { + return func(c *Client) { + c.timeout = d + c.hasTimeout = true + } +} + // Client is the struct to represent the functionality presented by the // https://ipdata.co API. // @@ -68,26 +89,17 @@ type Client struct { c *http.Client // http client e string // api endpoint k string // api key + + // timeout is the request time limit given to WithTimeout, and hasTimeout + // records that the option was used at all; newClient applies it to c. + timeout time.Duration + hasTimeout bool } // NewClient takes an API key and optional Options, and returns a Client that // uses the default endpoint (https://api.ipdata.co/). func NewClient(apiKey string, opts ...Option) (Client, error) { - if len(apiKey) == 0 { - return Client{}, errAPIKey - } - - c := Client{ - c: newHTTPClient(), - e: apiEndpoint, - k: apiKey, - } - - for _, opt := range opts { - opt(&c) - } - - return c, nil + return newClient(apiEndpoint, apiKey, opts) } // NewEUClient takes an API key and optional Options, and returns a Client that @@ -95,13 +107,19 @@ func NewClient(apiKey string, opts ...Option) (Client, error) { // requests are routed through EU data centers only (Frankfurt, Paris, Ireland), // which can be useful for GDPR compliance. func NewEUClient(apiKey string, opts ...Option) (Client, error) { + return newClient(euAPIEndpoint, apiKey, opts) +} + +// newClient builds a Client for endpoint, applies the options and then the +// request time limit set with WithTimeout, if any. +func newClient(endpoint, apiKey string, opts []Option) (Client, error) { if len(apiKey) == 0 { return Client{}, errAPIKey } c := Client{ c: newHTTPClient(), - e: euAPIEndpoint, + e: endpoint, k: apiKey, } @@ -109,6 +127,14 @@ func NewEUClient(apiKey string, opts ...Option) (Client, error) { opt(&c) } + if c.hasTimeout && c.c != nil { + // work on a copy: an *http.Client supplied through WithHTTPClient + // may be shared with other users + hc := *c.c + hc.Timeout = c.timeout + c.c = &hc + } + return c, nil } diff --git a/client_test.go b/client_test.go index 766bb21..9b78410 100644 --- a/client_test.go +++ b/client_test.go @@ -2781,3 +2781,162 @@ func TestClient_truncatedErrorResponse(t *testing.T) { }) } } + +func TestNewClient_WithTimeout(t *testing.T) { + t.Run("default_http_client", func(t *testing.T) { + c, err := NewClient("testAPIkey", WithTimeout(5*time.Second)) + if err != nil { + t.Fatalf("NewClient() unexpected error: %v", err) + } + + if c.c == nil { + t.Fatal("c.c should not be nil") + } + + if got, want := c.c.Timeout, 5*time.Second; got != want { + t.Fatalf("c.c.Timeout = %s, want %s", got, want) + } + + if _, ok := c.c.Transport.(*http.Transport); !ok { + t.Fatalf("c.c.Transport is %T, want the *http.Transport of newHTTPClient()", c.c.Transport) + } + }) + + t.Run("custom_http_client", func(t *testing.T) { + rt := &http.Transport{} + custom := &http.Client{Transport: rt, Timeout: time.Minute} + + tests := []struct { + name string + opts []Option + }{ + { + name: "timeout_after_http_client", + opts: []Option{WithHTTPClient(custom), WithTimeout(5 * time.Second)}, + }, + { + name: "timeout_before_http_client", + opts: []Option{WithTimeout(5 * time.Second), WithHTTPClient(custom)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := NewEUClient("testAPIkey", tt.opts...) + if err != nil { + t.Fatalf("NewEUClient() unexpected error: %v", err) + } + + if c.c == custom { + t.Fatal("c.c is the caller's *http.Client, want a copy") + } + + if c.c.Transport != http.RoundTripper(rt) { + t.Fatalf("c.c.Transport = %#v, want the custom transport", c.c.Transport) + } + + if got, want := c.c.Timeout, 5*time.Second; got != want { + t.Fatalf("c.c.Timeout = %s, want %s", got, want) + } + + if got, want := custom.Timeout, time.Minute; got != want { + t.Fatalf("custom.Timeout = %s, want %s: the caller's client was modified", got, want) + } + }) + } + }) + + t.Run("zero_means_no_limit", func(t *testing.T) { + custom := &http.Client{Timeout: time.Minute} + + c, err := NewClient("testAPIkey", WithHTTPClient(custom), WithTimeout(0)) + if err != nil { + t.Fatalf("NewClient() unexpected error: %v", err) + } + + if c.c.Timeout != 0 { + t.Fatalf("c.c.Timeout = %s, want 0", c.c.Timeout) + } + + if got, want := custom.Timeout, time.Minute; got != want { + t.Fatalf("custom.Timeout = %s, want %s: the caller's client was modified", got, want) + } + }) + + t.Run("without_option", func(t *testing.T) { + c, err := NewClient("testAPIkey") + if err != nil { + t.Fatalf("NewClient() unexpected error: %v", err) + } + + if c.c.Timeout != 0 { + t.Fatalf("c.c.Timeout = %s, want 0", c.c.Timeout) + } + }) +} + +// TestClient_WithTimeout_request sends requests to a server that only answers +// once the client has given up, and checks that every lookup fails with a +// timeout error instead of waiting for that answer. +func TestClient_WithTimeout_request(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // reading the body lets the server notice the client going away, and + // cancel r.Context(), while the handler waits + _, _ = io.Copy(io.Discard, r.Body) + + select { + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + _, _ = io.WriteString(w, testJSONValid) + } + })) + defer server.Close() + + c, err := NewClient("testAPIkey", WithTimeout(100*time.Millisecond)) + if err != nil { + t.Fatalf("NewClient() unexpected error: %v", err) + } + + c.e = server.URL + "/" + + tests := []struct { + name string + call func() error + }{ + { + name: "Lookup", + call: func() error { + _, err := c.Lookup("8.8.8.8") + return err + }, + }, + { + name: "LookupFields", + call: func() error { + _, err := c.LookupFields("8.8.8.8", []string{"ip"}) + return err + }, + }, + { + name: "BulkLookup", + call: func() error { + _, err := c.BulkLookup([]string{"1.1.1.1", "8.8.8.8"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if err == nil { + t.Fatalf("%s() returned no error, want a timeout", tt.name) + } + + var nerr net.Error + if !errors.As(err, &nerr) || !nerr.Timeout() { + t.Fatalf("%s() error = %v, want an error whose Timeout() reports true", tt.name, err) + } + }) + } +} diff --git a/doc.go b/doc.go index 09b21d7..6675b31 100644 --- a/doc.go +++ b/doc.go @@ -26,5 +26,7 @@ // server negotiates it) for as long as it lives, so build one Client and reuse // it for every lookup instead of creating one per request. A Client is safe // for concurrent use by multiple goroutines; call CloseIdleConnections at -// shutdown to release the pooled connections. +// shutdown to release the pooled connections. A request has no overall time +// limit unless the Client was built with WithTimeout or a context with a +// deadline is passed to the WithContext methods. package ipdata From 2068f7b00436fa433620a140b4fff7d0ddcd799c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:56:12 +0000 Subject: [PATCH 4/5] Fix stale and missing documentation doc.go pointed issue reports at the repository's former location; it now names github.com/ipdata/go/issues. Scores had no doc comment and the Threat comment did not mention its Blocklists and Scores fields; both are documented now, and the README's "Nested Types" section gains the Scores entry it was missing. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 ++ doc.go | 2 +- types.go | 9 ++++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fcca2cb..3f92790 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,8 @@ The `IP` struct includes all fields from the ipdata API response: - `LastSeen` (`string`): last day the address was listed, as `YYYY-MM-DD` (UTC); empty when not reported - `DaysSeen` (`*int`): number of days the address was listed; `nil` when not reported +**`Scores`** (`Threat.Scores`): `VPNScore`, `ProxyScore`, `ThreatScore`, `TrustScore`; each is a number from 0 to 100, and a higher `TrustScore` means a more trustworthy address + ## Contributors - [Tim Heckman](https://github.com/theckman/) - Created the first version of this library diff --git a/doc.go b/doc.go index 6675b31..424c12e 100644 --- a/doc.go +++ b/doc.go @@ -9,7 +9,7 @@ // // If you have any problems with this client, please raise an issue on GitHub: // -// * https://github.com/theckman/go-ipdata/issues +// * https://github.com/ipdata/go/issues // // Example usage: // diff --git a/types.go b/types.go index 4b52b71..010eab0 100644 --- a/types.go +++ b/types.go @@ -112,7 +112,9 @@ type TimeZone struct { } // Threat represents the threat object within the JSON response from the API. -// This provides information about what type of threat this IP may be. +// This provides information about what type of threat this IP may be: the +// boolean fields classify the address, Blocklists lists the feeds that include +// it (see Blocklist) and Scores holds the threat scores (see Scores). type Threat struct { IsTOR bool `json:"is_tor"` IsVPN bool `json:"is_vpn"` @@ -143,6 +145,11 @@ type Blocklist struct { DaysSeen *int `json:"days_seen,omitempty"` } +// Scores represents the scores object within the threat object of the JSON +// response from the API. Each score is a number from 0 to 100: VPNScore, +// ProxyScore and ThreatScore give how likely the IP address is a VPN, a proxy +// or a threat, and TrustScore how trustworthy it is, a higher value meaning a +// more trustworthy address. type Scores struct { VPNScore int `json:"vpn_score"` ProxyScore int `json:"proxy_score"` From 08f7c715f42e941f26abc14efd4ac9f3216c0af7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:56:12 +0000 Subject: [PATCH 5/5] Test with Go 1.25, 1.26 and 1.27 in CI go.mod requires Go 1.24.7, so the 1.21, 1.22 and 1.23 jobs switch to the 1.24.7 toolchain through GOTOOLCHAIN=auto and the matrix exercised only Go 1.24. Add the releases since then so the tests also run on current toolchains; the existing entries are kept. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bf00e0..5f1e7ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: ['1.21', '1.22', '1.23', '1.24'] + go-version: ['1.21', '1.22', '1.23', '1.24', '1.25', '1.26', '1.27'] steps: - uses: actions/checkout@v4