From b3a1f2fe82353798fc0726d8f1d96fc0105666d2 Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Mon, 9 Nov 2026 09:05:51 +0000 Subject: [PATCH] fix(go): retry transient Cloud API read failures Route bodyless GET/HEAD requests through the existing retry helper. Cover quota waits longer than the HTTP timeout, deadline cancellation, and unchanged POST retry behavior. Keep CI compatible with the current runner images and pinned Bun version. --- .github/workflows/cli-checks.yml | 6 ++- .github/workflows/pr-checks.yml | 7 ++-- go/CHANGELOG.md | 4 ++ go/read_retry_test.go | 66 ++++++++++++++++++++++++++++++++ go/request.go | 13 +++++++ 5 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 go/read_retry_test.go diff --git a/.github/workflows/cli-checks.yml b/.github/workflows/cli-checks.yml index b5245fec6..6dd3acdff 100644 --- a/.github/workflows/cli-checks.yml +++ b/.github/workflows/cli-checks.yml @@ -6,10 +6,12 @@ on: paths: - 'cli/**' - 'js/**' + - '.github/workflows/cli-checks.yml' push: paths: - 'cli/**' - 'js/**' + - '.github/workflows/cli-checks.yml' jobs: cli-checks: @@ -22,9 +24,9 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version-file: ".bun-version" - name: Install workspace dependencies working-directory: . diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 3fd29ce33..a9f6c30a3 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -198,7 +198,7 @@ jobs: - name: Run Go tests (exclude live-API e2e) working-directory: go - run: go test $(go list ./... | grep -v /e2e) -count=1 + run: go test -race $(go list ./... | grep -v /e2e) -count=1 compatibility-checks: name: Compatibility Checks @@ -276,7 +276,8 @@ jobs: browser-compatibility: name: Browser Compatibility - runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }} + # Match the Ubuntu 24.04 ABI used by Playwright's WebKit binary. + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -463,4 +464,4 @@ jobs: name: browser-check-results path: | js/browser_report.txt - pr-metadata/ \ No newline at end of file + pr-metadata/ diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index 025734636..9b7901b63 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Retry transient failures on bodyless GET/HEAD requests using the existing retry budget, `Retry-After` handling, and context cancellation. + ## [](https://github.com/Phala-Network/phala-cloud/compare/sdks/go/v0.2.0...sdks/go/v) (2026-11-07) ### ⚠ BREAKING CHANGE diff --git a/go/read_retry_test.go b/go/read_retry_test.go new file mode 100644 index 000000000..bac1c340b --- /dev/null +++ b/go/read_retry_test.go @@ -0,0 +1,66 @@ +package phala + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "testing/synctest" + "time" +) + +type retryTransport func(*http.Request) (*http.Response, error) + +func (f retryTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestAutomaticReadRetries(t *testing.T) { + for _, tc := range []struct { + name, method string + timeout time.Duration + }{ + {"GET", "GET", 5 * time.Minute}, + {"HEAD", "HEAD", 5 * time.Minute}, + {"POST", "POST", 5 * time.Minute}, + {"deadline", "GET", 45 * time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + calls, start := 0, time.Now() + client, err := NewClient(WithAPIKey("test-key"), WithMaxRetries(1), WithHTTPClient(&http.Client{ + Timeout: 30 * time.Second, + Transport: retryTransport(func(*http.Request) (*http.Response, error) { + calls++ + status := http.StatusOK + if calls == 1 { + status = http.StatusTooManyRequests + } + return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": {"60"}}, Body: io.NopCloser(strings.NewReader("{}"))}, nil + }), + })) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) + defer cancel() + err = client.doJSON(ctx, tc.method, "/test", nil, nil) + switch tc.name { + case "POST": + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 429 || calls != 1 { + t.Fatalf("calls=%d err=%v", calls, err) + } + case "deadline": + if !errors.Is(err, context.DeadlineExceeded) || calls != 1 { + t.Fatalf("calls=%d err=%v", calls, err) + } + default: + if err != nil || calls != 2 || time.Since(start) != time.Minute { + t.Fatalf("calls=%d elapsed=%s err=%v", calls, time.Since(start), err) + } + } + }) + }) + } +} diff --git a/go/request.go b/go/request.go index 1c743fcb9..78ec03042 100644 --- a/go/request.go +++ b/go/request.go @@ -45,6 +45,19 @@ func (c *Client) newRequest(ctx context.Context, method, path string, body io.Re // do executes an HTTP request and returns the response. Non-2xx responses return *APIError. func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) { + if (req.Method != http.MethodGet && req.Method != http.MethodHead) || + (req.Body != nil && req.Body != http.NoBody) { + return c.doOnce(req) + } + var resp *http.Response + err := c.doWithRetry(ctx, func() (err error) { + resp, err = c.doOnce(req) + return err + }) + return resp, err +} + +func (c *Client) doOnce(req *http.Request) (*http.Response, error) { resp, err := c.httpClient.Do(req) if err != nil { return nil, err