Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/cmd/gmail_track_opens.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (c *GmailTrackOpensCmd) queryByTrackingID(ctx context.Context, cfg *trackin
return fmt.Errorf("build request: %w", err)
}

resp, err := http.DefaultClient.Do(req)
resp, err := outboundHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("query tracker: %w", err)
}
Expand Down Expand Up @@ -136,7 +136,7 @@ func (c *GmailTrackOpensCmd) queryAdmin(ctx context.Context, cfg *tracking.Confi
req, _ := http.NewRequestWithContext(ctx, "GET", reqURL.String(), nil)
req.Header.Set("Authorization", "Bearer "+cfg.AdminKey)

resp, err := http.DefaultClient.Do(req)
resp, err := outboundHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("query tracker: %w", err)
}
Expand Down
9 changes: 9 additions & 0 deletions internal/cmd/http_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package cmd

import (
"github.com/openclaw/gogcli/internal/googleapi"
)

// outboundHTTPClient bounds response-header wait for unauthenticated
// fetches (tracking queries, media downloads, slide thumbnails).
var outboundHTTPClient = googleapi.NewBoundedHTTPClient()
116 changes: 116 additions & 0 deletions internal/cmd/http_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package cmd

import (
"context"
"errors"
"io"
"net/http"
"path/filepath"
"strings"
"testing"

"github.com/openclaw/gogcli/internal/googleapi"
"github.com/openclaw/gogcli/internal/tracking"
"github.com/openclaw/gogcli/internal/ui"
)

type stubRoundTripper struct {
fn func(*http.Request) (*http.Response, error)
}

func (s stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return s.fn(req)
}

func swapOutboundHTTPClient(t *testing.T, client *http.Client) {
t.Helper()
old := outboundHTTPClient
outboundHTTPClient = client
t.Cleanup(func() { outboundHTTPClient = old })
}

func TestOutboundHTTPClientIsBounded(t *testing.T) {
if outboundHTTPClient == http.DefaultClient {
t.Fatal("outboundHTTPClient is http.DefaultClient")
}
tr, ok := outboundHTTPClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", outboundHTTPClient.Transport)
}
if tr.ResponseHeaderTimeout == 0 {
t.Fatal("ResponseHeaderTimeout is 0")
}
}

func TestQueryByTrackingIDUsesOutboundHTTPClient(t *testing.T) {
var saw string
swapOutboundHTTPClient(t, &http.Client{
Transport: stubRoundTripper{fn: func(req *http.Request) (*http.Response, error) {
saw = req.URL.String()
return nil, errors.New("sentinel-outbound")
}},
})

u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
cmd := &GmailTrackOpensCmd{TrackingID: "tid-1"}
err = cmd.queryByTrackingID(context.Background(), &tracking.Config{WorkerURL: "http://tracker.example"}, u)
if err == nil || !strings.Contains(err.Error(), "sentinel-outbound") {
t.Fatalf("queryByTrackingID error = %v", err)
}
if saw != "http://tracker.example/q/tid-1" {
t.Fatalf("request URL = %q", saw)
}
}

func TestQueryAdminUsesOutboundHTTPClient(t *testing.T) {
var sawAuth string
swapOutboundHTTPClient(t, &http.Client{
Transport: stubRoundTripper{fn: func(req *http.Request) (*http.Response, error) {
sawAuth = req.Header.Get("Authorization")
return nil, errors.New("sentinel-outbound")
}},
})

u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
cmd := &GmailTrackOpensCmd{}
err = cmd.queryAdmin(context.Background(), &tracking.Config{WorkerURL: "http://tracker.example", AdminKey: "secret"}, u)
if err == nil || !strings.Contains(err.Error(), "sentinel-outbound") {
t.Fatalf("queryAdmin error = %v", err)
}
if sawAuth != "Bearer secret" {
t.Fatalf("Authorization = %q", sawAuth)
}
}

func TestDownloadSlidesThumbnailUsesOutboundHTTPClient(t *testing.T) {
swapOutboundHTTPClient(t, &http.Client{
Transport: stubRoundTripper{fn: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("sentinel-outbound")
}},
})

_, _, err := downloadSlidesThumbnail(context.Background(), "http://cdn.example/thumb.png", filepath.Join(t.TempDir(), "t.png"), true)
if err == nil || !strings.Contains(err.Error(), "sentinel-outbound") {
t.Fatalf("downloadSlidesThumbnail error = %v", err)
}
}

func TestNewBoundedHTTPClientMatchesAuthenticatedTransportTimeout(t *testing.T) {
client := googleapi.NewBoundedHTTPClient()
if client == http.DefaultClient {
t.Fatal("NewBoundedHTTPClient returned DefaultClient")
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", client.Transport)
}
if tr.ResponseHeaderTimeout == 0 {
t.Fatal("ResponseHeaderTimeout is 0")
}
}
2 changes: 1 addition & 1 deletion internal/cmd/photos.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ func (c *PhotosDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return fmt.Errorf("build media download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
resp, err := outboundHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("download media item: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/slides_thumbnail.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func downloadSlidesThumbnail(ctx context.Context, url, outputPath string, overwr
return 0, "", fmt.Errorf("build thumbnail download request: %w", err)
}

resp, err := http.DefaultClient.Do(req)
resp, err := outboundHTTPClient.Do(req)
if err != nil {
return 0, "", fmt.Errorf("download thumbnail: %w", err)
}
Expand Down
8 changes: 8 additions & 0 deletions internal/googleapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,14 @@ func newBaseTransport() *http.Transport {
return transport
}

// NewBoundedHTTPClient returns an unauthenticated client with the same
// ResponseHeaderTimeout used by authenticated Google clients. It does not
// set Client.Timeout so large downloads are not cut short after headers
// arrive.
func NewBoundedHTTPClient() *http.Client {
return &http.Client{Transport: newBaseTransport()}
}

// reauthFunctionFromContext builds a Reauth closure from the auth
// dependencies stored in the context. Returns nil if the dependencies are
// not available or the Reauth function is not configured, in which case
Expand Down
20 changes: 20 additions & 0 deletions internal/googleapi/client_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,26 @@ func TestNewBaseTransport_SetsResponseHeaderTimeout(t *testing.T) {
}
}

func TestNewBoundedHTTPClient_SetsResponseHeaderTimeout(t *testing.T) {
client := NewBoundedHTTPClient()
if client == http.DefaultClient {
t.Fatal("NewBoundedHTTPClient returned DefaultClient")
}

transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", client.Transport)
}

if transport.ResponseHeaderTimeout != responseHeaderTimeout {
t.Fatalf("expected ResponseHeaderTimeout=%v, got %v", responseHeaderTimeout, transport.ResponseHeaderTimeout)
}

if client.Timeout != 0 {
t.Fatalf("expected no Client.Timeout, got %v", client.Timeout)
}
}

func TestOptionsForAccountScopes_NoClientTimeout(t *testing.T) {
opts, err := optionsForAccountScopes(testClientResolverContext(t), "svc", "a@b.com", []string{"s1"})
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/googleapi/photos.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func WithPhotosBaseURL(baseURL string) PhotosClientOption {

func NewPhotosClient(client *http.Client, opts ...PhotosClientOption) *PhotosClient {
if client == nil {
client = http.DefaultClient
client = NewBoundedHTTPClient()
}

c := &PhotosClient{
Expand Down
38 changes: 38 additions & 0 deletions internal/googleapi/photos_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package googleapi

import (
"net/http"
"testing"
)

func TestNewPhotosClientNilUsesBoundedClient(t *testing.T) {
client := NewPhotosClient(nil)
if client.client == http.DefaultClient {
t.Fatal("nil PhotosClient fell back to DefaultClient")
}

tr, ok := client.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", client.client.Transport)
}

if tr.ResponseHeaderTimeout != responseHeaderTimeout {
t.Fatalf("ResponseHeaderTimeout = %v", tr.ResponseHeaderTimeout)
}
}

func TestNewPhotosPickerClientNilUsesBoundedClient(t *testing.T) {
client := NewPhotosPickerClient(nil)
if client.client == http.DefaultClient {
t.Fatal("nil PhotosPickerClient fell back to DefaultClient")
}

tr, ok := client.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", client.client.Transport)
}

if tr.ResponseHeaderTimeout != responseHeaderTimeout {
t.Fatalf("ResponseHeaderTimeout = %v", tr.ResponseHeaderTimeout)
}
}
2 changes: 1 addition & 1 deletion internal/googleapi/photos_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func WithPhotosPickerBaseURL(baseURL string) PhotosPickerClientOption {

func NewPhotosPickerClient(client *http.Client, opts ...PhotosPickerClientOption) *PhotosPickerClient {
if client == nil {
client = http.DefaultClient
client = NewBoundedHTTPClient()
}

c := &PhotosPickerClient{
Expand Down
2 changes: 1 addition & 1 deletion internal/zoom/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func NewClient(alias string, credentials Credentials, tokens TokenStore, opts ..
ClientSecret: strings.TrimSpace(credentials.ClientSecret),
},
alias: NormalizeAlias(alias),
httpClient: http.DefaultClient,
httpClient: &http.Client{Timeout: 30 * time.Second},
now: time.Now,
tokens: tokens,
}
Expand Down
20 changes: 20 additions & 0 deletions internal/zoom/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,23 @@ func TestRedactZoomURL(t *testing.T) {
t.Fatalf("expected include passwords env")
}
}

func TestNewClientDefaultHTTPClientIsBounded(t *testing.T) {
store, _ := newTestStore(t)
client, err := NewClient("work", Credentials{
AccountID: "acct",
ClientID: "client",
ClientSecret: "secret",
}, store)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
if client.httpClient == http.DefaultClient {
t.Fatal("NewClient used http.DefaultClient")
}
if client.httpClient.Timeout == 0 {
if tr, ok := client.httpClient.Transport.(*http.Transport); !ok || tr.ResponseHeaderTimeout == 0 {
t.Fatal("default Zoom HTTP client has no timeout")
}
}
}