Skip to content

Commit 227b04f

Browse files
fix(crawler): skip forbidden crawlers in list and surface non-JSON API errors (#267)
1 parent dd71e3b commit 227b04f

5 files changed

Lines changed: 162 additions & 15 deletions

File tree

‎api/crawler/client.go‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ package crawler
33
import (
44
"bytes"
55
"encoding/json"
6-
"errors"
76
"fmt"
87
"io"
98
"net/http"
9+
"strings"
1010
)
1111

1212
const (
@@ -60,25 +60,31 @@ func (c *Client) request(
6060
}
6161

6262
if resp.StatusCode >= 400 {
63-
var errResp ErrResponse
64-
if err := unmarshalTo(resp, &errResp); err != nil {
65-
return err
66-
}
63+
raw, _ := io.ReadAll(resp.Body)
64+
resp.Body.Close()
6765

68-
if errResp.Err.Errors != nil {
69-
var errs []string
70-
for _, e := range errResp.Err.Errors {
71-
errs = append(errs, e.Message)
66+
message := strings.TrimSpace(string(raw))
67+
68+
var errResp ErrResponse
69+
if json.Unmarshal(raw, &errResp) == nil {
70+
if errResp.Err.Errors != nil {
71+
var errs []string
72+
for _, e := range errResp.Err.Errors {
73+
errs = append(errs, e.Message)
74+
}
75+
message = fmt.Sprintf("[%s] %s", errResp.Err.Code, errs)
76+
} else if errResp.Err.Message != "" {
77+
message = fmt.Sprintf("[%s] %s", errResp.Err.Code, errResp.Err.Message)
78+
} else if errResp.Err.Code != "" {
79+
message = errResp.Err.Code
7280
}
73-
return fmt.Errorf("[%s] %s", errResp.Err.Code, errs)
7481
}
7582

76-
// Message might be empty
77-
if errResp.Err.Message == "" {
78-
return errors.New(errResp.Err.Code)
79-
} else {
80-
return fmt.Errorf("[%s] %s", errResp.Err.Code, errResp.Err.Message)
83+
if message == "" {
84+
message = http.StatusText(resp.StatusCode)
8185
}
86+
87+
return &APIError{StatusCode: resp.StatusCode, Message: message}
8288
}
8389

8490
if res != nil {

‎api/crawler/client_test.go‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package crawler
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
)
9+
10+
type roundTripFunc func(*http.Request) (*http.Response, error)
11+
12+
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
13+
return f(r)
14+
}
15+
16+
func newTestClient(fn roundTripFunc) *Client {
17+
return NewClientWithHTTPClient("user", "key", &http.Client{Transport: fn})
18+
}
19+
20+
func TestRequest_PlainTextError(t *testing.T) {
21+
client := newTestClient(func(_ *http.Request) (*http.Response, error) {
22+
return &http.Response{
23+
StatusCode: http.StatusForbidden,
24+
Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}},
25+
Body: io.NopCloser(strings.NewReader("Forbidden")),
26+
}, nil
27+
})
28+
29+
_, err := client.Get("some-id", true)
30+
if err == nil {
31+
t.Fatal("expected an error, got nil")
32+
}
33+
34+
apiErr, ok := err.(*APIError)
35+
if !ok {
36+
t.Fatalf("expected *APIError, got %T: %v", err, err)
37+
}
38+
if apiErr.StatusCode != http.StatusForbidden {
39+
t.Errorf("expected status 403, got %d", apiErr.StatusCode)
40+
}
41+
if apiErr.Error() != "Forbidden" {
42+
t.Errorf("expected message %q, got %q", "Forbidden", apiErr.Error())
43+
}
44+
}

‎api/crawler/types.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ import (
66
"github.com/algolia/algoliasearch-client-go/v4/algolia/search"
77
)
88

9+
// APIError is an error returned by the Crawler API, carrying the HTTP status.
10+
type APIError struct {
11+
StatusCode int
12+
Message string
13+
}
14+
15+
func (e *APIError) Error() string {
16+
return e.Message
17+
}
18+
919
// ErrResponse is a Crawler API error response.
1020
type ErrResponse struct {
1121
Err Err `json:"error"`

‎pkg/cmd/crawler/list/list.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package list
22

33
import (
4+
"errors"
45
"fmt"
6+
"net/http"
57
"sort"
68

79
"github.com/MakeNowJust/heredoc"
@@ -88,6 +90,10 @@ func runListCmd(opts *ListOptions) error {
8890
opts.IO.UpdateProgressIndicatorLabel(fmt.Sprintf("Fetching Crawler %s details", item.ID))
8991
c, err := client.Get(item.ID, true)
9092
if err != nil {
93+
var apiErr *crawler.APIError
94+
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden {
95+
continue
96+
}
9197
opts.IO.StopProgressIndicator()
9298
return err
9399
}

‎pkg/cmd/crawler/list/list_test.go‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package list
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/algolia/cli/api/crawler"
13+
"github.com/algolia/cli/pkg/cmdutil"
14+
"github.com/algolia/cli/pkg/iostreams"
15+
)
16+
17+
type roundTripFunc func(*http.Request) (*http.Response, error)
18+
19+
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
20+
return f(r)
21+
}
22+
23+
func jsonResponse(status int, body string) *http.Response {
24+
return &http.Response{
25+
StatusCode: status,
26+
Header: http.Header{"Content-Type": []string{"application/json"}},
27+
Body: io.NopCloser(strings.NewReader(body)),
28+
}
29+
}
30+
31+
func Test_runListCmd_skipsForbiddenCrawlers(t *testing.T) {
32+
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
33+
switch {
34+
case strings.HasSuffix(r.URL.Path, "/crawlers"):
35+
return jsonResponse(200, `{
36+
"items": [
37+
{"id": "ok-id", "name": "accessible"},
38+
{"id": "forbidden-id", "name": "not-mine"}
39+
],
40+
"page": 1, "itemsPerPage": 20, "total": 2
41+
}`), nil
42+
case strings.Contains(r.URL.Path, "forbidden-id"):
43+
return &http.Response{
44+
StatusCode: http.StatusForbidden,
45+
Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}},
46+
Body: io.NopCloser(strings.NewReader("Forbidden")),
47+
}, nil
48+
case strings.Contains(r.URL.Path, "ok-id"):
49+
return jsonResponse(200, `{
50+
"name": "accessible",
51+
"createdAt": "2026-01-01T00:00:00.000Z",
52+
"updatedAt": "2026-01-02T00:00:00.000Z",
53+
"running": false,
54+
"blocked": false,
55+
"config": {"appId": "APP_ID"}
56+
}`), nil
57+
default:
58+
t.Fatalf("unexpected request: %s", r.URL.Path)
59+
return nil, nil
60+
}
61+
})
62+
63+
client := crawler.NewClientWithHTTPClient("user", "key", &http.Client{Transport: transport})
64+
65+
ios, _, stdout, _ := iostreams.Test()
66+
printFlags := cmdutil.NewPrintFlags()
67+
printFlags.OutputFlagSpecified = func() bool { return false }
68+
opts := &ListOptions{
69+
IO: ios,
70+
CrawlerClient: func() (*crawler.Client, error) {
71+
return client, nil
72+
},
73+
PrintFlags: printFlags,
74+
}
75+
76+
err := runListCmd(opts)
77+
require.NoError(t, err)
78+
79+
assert.Contains(t, stdout.String(), "accessible")
80+
assert.NotContains(t, stdout.String(), "not-mine")
81+
}

0 commit comments

Comments
 (0)