feat(cmd): wire --provider and --base-url flags into CLI (Phase 5) #106
@@ -91,6 +91,10 @@ func main() {
|
|||||||
// NOTE: If a user passes both --vcs-url and --gitea-url, the last one on
|
// NOTE: If a user passes both --vcs-url and --gitea-url, the last one on
|
||||||
// the command line takes effect (standard flag package behavior). This is
|
// the command line takes effect (standard flag package behavior). This is
|
||||||
// acceptable since --gitea-url is deprecated and both serve the same purpose.
|
// acceptable since --gitea-url is deprecated and both serve the same purpose.
|
||||||
|
//
|
||||||
|
// ORDERING: This must remain AFTER vcsURL's flag.String declaration and BEFORE
|
||||||
|
// flag.Parse(). The *vcsURL dereference captures the env-var-resolved default
|
||||||
|
// at registration time; moving flag.Parse() above this line would break it.
|
||||||
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
|
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
+83
-24
@@ -387,11 +387,15 @@ func (c *Client) doRequestWithBody(ctx context.Context, method, reqURL string, r
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
|||||||
// doJSONRequest performs an HTTP request with a JSON body and returns the response body.
|
// doJSONRequest performs an HTTP request with a JSON body and returns the response body.
|
||||||
// It handles HTTPS validation, authentication, and response reading.
|
// It handles HTTPS validation, authentication, response reading, and retries on HTTP 429
|
||||||
|
// rate limit responses (matching the retry behavior of doRequest).
|
||||||
// This is a general-purpose helper used by any method that needs to send JSON payloads
|
// This is a general-purpose helper used by any method that needs to send JSON payloads
|
||||||
// (e.g. PostReview, DismissReview).
|
// (e.g. PostReview, DismissReview).
|
||||||
func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, payload any) ([]byte, error) {
|
func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, payload any) ([]byte, error) {
|
||||||
const maxErrorBodyBytes = 4 * 1024
|
const (
|
||||||
|
sonnet-review-bot
commented
[MINOR] **[MINOR]** `doJSONRequest` is added to `client.go` but is only used internally by `review.go` methods. Placing transport-layer helpers (`doRequestCore`, `doRequestWithBody`) together with a higher-level JSON serialisation helper makes the file slightly inconsistent. This is a minor organisation concern — a comment grouping the helpers or moving `doJSONRequest` to `review.go` would improve cohesion, but the current placement is not wrong.
|
|||||||
|
maxErrorBodyBytes = 4 * 1024
|
||||||
|
maxRetryAfter = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
jsonBody, err := json.Marshal(payload)
|
jsonBody, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -408,34 +412,89 @@ func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, paylo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonBody))
|
defaultBackoff := []time.Duration{1 * time.Second, 2 * time.Second}
|
||||||
if err != nil {
|
var backoff []time.Duration
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
if c.retryBackoff != nil && len(c.retryBackoff) == maxRetryAttempts-1 {
|
||||||
|
backoff = make([]time.Duration, len(c.retryBackoff))
|
||||||
|
copy(backoff, c.retryBackoff)
|
||||||
|
} else {
|
||||||
|
backoff = make([]time.Duration, len(defaultBackoff))
|
||||||
|
copy(backoff, defaultBackoff)
|
||||||
}
|
}
|
||||||
if c.token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
var lastErr error
|
||||||
if err != nil {
|
for attempt := 0; attempt < maxRetryAttempts; attempt++ {
|
||||||
return nil, fmt.Errorf("do request: %w", err)
|
if attempt > 0 {
|
||||||
}
|
var delay time.Duration
|
||||||
defer resp.Body.Close()
|
if attempt-1 < len(backoff) {
|
||||||
|
delay = backoff[attempt-1]
|
||||||
|
}
|
||||||
|
if delay > 0 {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
timer.Stop()
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonBody))
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxResponseBytes)+1))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read response body: %w", err)
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
if len(body) > maxResponseBytes {
|
if c.token != "" {
|
||||||
return nil, fmt.Errorf("response body exceeded %d bytes", maxResponseBytes)
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
}
|
}
|
||||||
return body, nil
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("do request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
respStatus := resp.StatusCode
|
||||||
|
retryAfterHeader := resp.Header.Get("Retry-After")
|
||||||
|
|
||||||
|
body, done, handleErr := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
||||||
|
if done {
|
||||||
|
return body, handleErr
|
||||||
|
}
|
||||||
|
lastErr = handleErr
|
||||||
|
|
||||||
|
// Retry on 429 rate limit
|
||||||
|
if respStatus == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
||||||
|
if ra := retryAfterHeader; ra != "" {
|
||||||
|
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
|
||||||
|
delay := time.Duration(seconds) * time.Second
|
||||||
|
if delay > maxRetryAfter {
|
||||||
|
delay = maxRetryAfter
|
||||||
|
}
|
||||||
|
if attempt < len(backoff) {
|
||||||
|
backoff[attempt] = delay
|
||||||
|
}
|
||||||
|
} else if retryAt, err := http.ParseTime(ra); err == nil {
|
||||||
|
delay := time.Until(retryAt)
|
||||||
|
if delay < 0 {
|
||||||
|
delay = 0
|
||||||
|
}
|
||||||
|
if delay > maxRetryAfter {
|
||||||
|
delay = maxRetryAfter
|
||||||
|
}
|
||||||
|
if attempt < len(backoff) {
|
||||||
|
backoff[attempt] = delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxErrorBodyBytes)))
|
return nil, lastErr
|
||||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package github
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -592,3 +593,55 @@ func TestSetRetryBackoff_RejectsInvalidLength(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error for valid backoff: %v", err)
|
t.Fatalf("unexpected error for valid backoff: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDoJSONRequest_429Retry(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit exceeded"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"id":1}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", ts.URL, AllowInsecureHTTP())
|
||||||
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
||||||
|
|
||||||
|
body, err := c.doJSONRequest(context.Background(), http.MethodPost, ts.URL+"/test", map[string]string{"key": "val"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 3 {
|
||||||
|
t.Errorf("expected 3 attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
if string(body) != `{"id":1}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSONRequest_429ExhaustsRetries(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit"}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", ts.URL, AllowInsecureHTTP())
|
||||||
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
||||||
|
|
||||||
|
_, err := c.doJSONRequest(context.Background(), http.MethodPost, ts.URL+"/test", map[string]string{"key": "val"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after exhausting retries")
|
||||||
|
}
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 429 {
|
||||||
|
t.Errorf("expected 429, got %d", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user
[NIT] doJSONRequest is added but not yet called by any public methods in the diff (the github package has no PostReview/DismissReview/etc. visible in the diff). This is fine for an incremental PR, but it means the new helper is untested at the integration level. The unit tests for doJSONRequest itself are present and cover the retry path.