aboutsummaryrefslogtreecommitdiffstats
path: root/fiken/transport.go
diff options
context:
space:
mode:
Diffstat (limited to 'fiken/transport.go')
-rw-r--r--fiken/transport.go174
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)
+}