diff options
Diffstat (limited to 'fiken/transport.go')
| -rw-r--r-- | fiken/transport.go | 174 |
1 files changed, 174 insertions, 0 deletions
diff --git a/fiken/transport.go b/fiken/transport.go new file mode 100644 index 0000000..9c27871 --- /dev/null +++ b/fiken/transport.go @@ -0,0 +1,174 @@ +package fiken + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "simenandre.no/fiken/internal/assert" +) + +// APIError is a request Fiken refused. It carries what Fiken said, because the +// operator can usually act on it directly. +type APIError struct { + Status int + Method string + Path string + Message string +} + +func (e *APIError) Error() string { + if e.Message == "" { + return fmt.Sprintf("fiken %s %s: HTTP %d", e.Method, e.Path, e.Status) + } + return fmt.Sprintf("fiken %s %s: HTTP %d: %s", e.Method, e.Path, e.Status, e.Message) +} + +// do performs one API call, retrying only what is safe to retry: rate limits +// and server faults. A 4xx is the caller's problem and is returned at once. +func (c *Client) do(ctx context.Context, method, path string, query url.Values, body []byte) (*http.Response, error) { + return c.send(ctx, method, path, query, body, maxAttempts) +} + +// doOnce is do without retries, for a call that is not safe to repeat: a 5xx +// does not say whether Fiken acted on the request before failing. +func (c *Client) doOnce(ctx context.Context, method, path string, query url.Values, body []byte) (*http.Response, error) { + return c.send(ctx, method, path, query, body, 1) +} + +func (c *Client) send(ctx context.Context, method, path string, query url.Values, body []byte, attempts int) (*http.Response, error) { + assert.True(attempts >= 1 && attempts <= maxAttempts, "attempts %d out of range", attempts) + assert.True(strings.HasPrefix(path, "/"), "path %q must start with a slash", path) + + target := c.baseURL + path + if len(query) > 0 { + target += "?" + query.Encode() + } + + // Fetched once, before the retry loop: a refresh in the middle of a backoff + // would be answering a question nobody asked. + token, err := c.tokens.Token(ctx) + if err != nil { + return nil, err + } + + var lastErr error + for attempt := 1; attempt <= attempts; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, target, reader) + if err != nil { + return nil, fmt.Errorf("build request for %s: %w", path, err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + lastErr = fmt.Errorf("fiken %s %s: %w", method, path, err) + } else if retryable(resp.StatusCode) { + lastErr = c.apiError(resp, method, path) + } else if resp.StatusCode >= 400 { + return nil, c.apiError(resp, method, path) + } else { + return resp, nil + } + + if attempt < attempts { + c.sleep(retryBackoff * (1 << (attempt - 1))) + } + } + return nil, lastErr +} + +// retryable reports whether a status is worth trying again. A 429 means slow +// down and a 5xx means Fiken is having a moment; everything else is a decision. +func retryable(status int) bool { + return status == http.StatusTooManyRequests || status >= 500 +} + +// apiError consumes the response body and turns it into a readable error. It +// always closes the body, so the caller cannot leak it on the error path. +func (c *Client) apiError(resp *http.Response, method, path string) error { + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + err := &APIError{Status: resp.StatusCode, Method: method, Path: path, Message: message(raw)} + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("%w: %s", ErrUnauthorized, err.Message) + case http.StatusNotFound: + return fmt.Errorf("%w: %s", ErrNoSuchResource, err.Message) + } + return err +} + +// message pulls something human out of Fiken's error body, falling back to the +// raw text when it is not the shape we expect. +func message(raw []byte) string { + var body struct { + Message string `json:"message"` + Description string `json:"description"` + ValidationMessages []string `json:"validationMessages"` + } + if err := json.Unmarshal(raw, &body); err == nil { + switch { + case len(body.ValidationMessages) > 0: + return strings.Join(body.ValidationMessages, "; ") + case body.Message != "": + return body.Message + case body.Description != "": + return body.Description + } + } + return strings.TrimSpace(string(raw)) +} + +// page walks a paginated collection to the end. Fiken reports the total page +// count in a header, and the loop is bounded by maxPages regardless of what it +// says, so a misbehaving server cannot spin here forever. +func page[T any](ctx context.Context, c *Client, path string, query url.Values) ([]T, error) { + if query == nil { + query = url.Values{} + } + query.Set("pageSize", strconv.Itoa(maxPageSize)) + + var out []T + for pageNo := 0; pageNo < maxPages; pageNo++ { + query.Set("page", strconv.Itoa(pageNo)) + + resp, err := c.do(ctx, http.MethodGet, path, query, nil) + if err != nil { + return nil, err + } + + var batch []T + err = json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes)).Decode(&batch) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("fiken GET %s: unreadable response: %w", path, err) + } + out = append(out, batch...) + + total, convErr := strconv.Atoi(resp.Header.Get("Fiken-Api-Page-Count")) + if convErr != nil || pageNo+1 >= total || len(batch) == 0 { + return out, nil + } + } + return nil, fmt.Errorf("fiken GET %s: more than %d pages", path, maxPages) +} |
