Skip to content
Open
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
109 changes: 66 additions & 43 deletions internal/extendx/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
)

// DefaultHTTPTimeout is applied to the HTTP client every API call goes
// through, except uploads (see UploadOption). Tuned for the common
// through, except uploads (see withUpload). Tuned for the common
// case of small JSON request/response payloads.
const DefaultHTTPTimeout = 60 * time.Second

Expand Down Expand Up @@ -103,24 +103,18 @@ func NewClient(cfg Config) (*sdkclient.Client, error) {
}
}

// The SDK sets x-extend-api-version unconditionally in its core
// request_option.go (to "2026-02-09"). To override it we attach a
// default HTTPHeader; the SDK's header merge calls header.Set on
// our values AFTER cloning, so our entry wins. Same trick is used
// for X-Extend-Workspace-Id (no SDK option for it) and User-Agent
// (the SDK's default identifies the SDK library, not the CLI).
ua := cfg.UserAgent
if ua == "" {
ua = DefaultUserAgent
}
// X-Extend-Workspace-Id has no SDK option, so it rides on the
// SDK's default headers. User-Agent and x-extend-api-version can't:
// the SDK overwrites both on every request, so headerTransport sets
// them instead.
headers := http.Header{}
headers.Set("User-Agent", ua)
if cfg.APIVersion != "" {
headers.Set("x-extend-api-version", cfg.APIVersion)
}
if cfg.WorkspaceID != "" {
headers.Set("X-Extend-Workspace-Id", cfg.WorkspaceID)
}
ua := cfg.UserAgent
if ua == "" {
ua = DefaultUserAgent
}

// Build the underlying http.Client. Wrap with the debug transport
// when the caller asked for it. We don't share the http.Client
Expand All @@ -140,6 +134,11 @@ func NewClient(cfg Config) (*sdkclient.Client, error) {
pinnedBase = extend.Environments.Production
}
httpClient := &http.Client{
Transport: &headerTransport{
next: http.DefaultTransport,
userAgent: ua,
apiVersion: cfg.APIVersion,
},
Timeout: DefaultHTTPTimeout,
CheckRedirect: oauth.CheckSameOriginRedirect(pinnedBase),
}
Expand All @@ -153,19 +152,13 @@ func NewClient(cfg Config) (*sdkclient.Client, error) {
httpClient.Transport = NewDebugTransport(httpClient.Transport, cfg.Debug)
}

opts := []option.RequestOption{
option.WithHTTPHeader(headers),
option.WithHTTPClient(httpClient),
}
opts := []option.RequestOption{option.WithHTTPHeader(headers)}
if cfg.APIKey != "" {
opts = append(opts, option.WithToken(cfg.APIKey))
} else {
// OAuth auth is attached twice, and both paths matter:
// WithTokenFunc makes the SDK stamp a fresh token on every
// request, which covers uploads (UploadOption swaps in a bare
// http.Client, bypassing our transport). The bearer transport
// (outermost, so the debug transport logs both attempts) adds
// the 401 refresh-and-retry for everything else.
// request; the bearer transport (outermost, so the debug
// transport logs both attempts) adds the 401 refresh-and-retry.
src := cfg.OAuth
tokenCtx := cfg.TokenContext
if tokenCtx == nil {
Expand All @@ -176,6 +169,9 @@ func NewClient(cfg Config) (*sdkclient.Client, error) {
}))
httpClient.Transport = newBearerTransport(httpClient.Transport, cfg.OAuth)
}
uploadClient := uploadHTTPClient()
uploadClient.Transport = httpClient.Transport
opts = append(opts, option.WithHTTPClient(&routingClient{api: httpClient, upload: uploadClient}))
if baseURL != "" {
opts = append(opts, option.WithBaseURL(baseURL))
}
Expand Down Expand Up @@ -377,30 +373,57 @@ func populateFromBodyString(out *APIError, bodyErr error) {
out.Retryable = src.Retryable
}

// UploadOption returns a per-request option that swaps the SDK's
// configured *http.Client for an untimed one for the duration of a
// single call. Use on file uploads: end-to-end http.Client timeouts
// are counterproductive for large multipart bodies — a legitimate
// 100MB PDF over a flaky connection will exceed any reasonable
// per-request budget and surface as a vague "context deadline
// exceeded" rather than a fixable error. The caller's context is
// the only deadline that remains in effect.
//
// The Authorization, x-extend-api-version, X-Extend-Workspace-Id,
// and User-Agent headers are still attached by the SDK because they
// live on RequestOptions, not on the HTTP client; only the timeout
// knob changes.
func UploadOption() option.RequestOption {
return option.WithHTTPClient(uploadHTTPClient())
// headerTransport sets the CLI's User-Agent, and the API version when
// one is configured, on every request.
type headerTransport struct {
next http.RoundTripper
userAgent string
apiVersion string
}

func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("User-Agent", t.userAgent)
if t.apiVersion != "" {
req.Header.Set("x-extend-api-version", t.apiVersion)
}
return t.next.RoundTrip(req)
}

// routingClient sends requests made with a withUpload context through
// the upload client and everything else through the API client. Both
// share one transport chain.
type routingClient struct {
api *http.Client
upload *http.Client
}

func (c *routingClient) Do(req *http.Request) (*http.Response, error) {
if req.Context().Value(uploadKey{}) != nil {
return c.upload.Do(req)
}
return c.api.Do(req)
}

type uploadKey struct{}

// withUpload marks ctx so the request made with it goes through the
// untimed upload client. Use on file uploads: end-to-end http.Client
// timeouts are counterproductive for large multipart bodies — a
// legitimate 100MB PDF over a flaky connection will exceed any
// reasonable per-request budget and surface as a vague "context
// deadline exceeded" rather than a fixable error. The caller's context
// is the only deadline that remains in effect.
func withUpload(ctx context.Context) context.Context {
return context.WithValue(ctx, uploadKey{}, true)
}

// uploadHTTPClient is the untimed client UploadOption swaps in. It
// uploadHTTPClient is the untimed client uploads go through. It
// refuses redirects outright: upload requests carry the Authorization
// header, and following a redirect would replay it (and the multipart
// body) to whatever host the Location header names. The upload
// endpoint never redirects legitimately, and this client has no view
// of the configured API base to pin an origin against, so refusing is
// the safe policy.
// endpoint never redirects legitimately, so refusing is the safe
// policy.
func uploadHTTPClient() *http.Client {
return &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
Expand Down
48 changes: 48 additions & 0 deletions internal/extendx/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,51 @@ func TestUploadClientRefusesRedirect(t *testing.T) {
t.Errorf("error = %v, want the upload redirect refusal", err)
}
}

// The SDK sets its own User-Agent and x-extend-api-version on every
// request, so this checks what the server receives, on a regular call
// and on an upload, for both kinds of credentials.
func TestNewClient_SendsCLIHeaders(t *testing.T) {
const ua = "extend-cli/v1.2.3 (abc1234)"
const apiVersion = "2099-01-01"
for _, tc := range []struct {
name string
cfg Config
}{
{"api key", Config{APIKey: "sk_test"}},
{"oauth", Config{OAuth: &fakeSource{token: "eoat_live"}}},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("User-Agent"); got != ua {
t.Errorf("%s %s User-Agent = %q, want %q", r.Method, r.URL.Path, got, ua)
}
if got := r.Header.Get("x-extend-api-version"); got != apiVersion {
t.Errorf("%s %s x-extend-api-version = %q, want %q", r.Method, r.URL.Path, got, apiVersion)
}
if r.URL.Path == "/files/upload" {
fmt.Fprint(w, `{"object":"file","id":"file_1"}`)
return
}
fmt.Fprint(w, `{"object":"list","data":[]}`)
}))
defer srv.Close()

cfg := tc.cfg
cfg.BaseURL = srv.URL
cfg.UserAgent = ua
cfg.APIVersion = apiVersion
c, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
ctx := context.Background()
if _, err := c.Workflows.List(ctx, &extend.WorkflowsListRequest{}); err != nil {
t.Fatalf("List: %v", err)
}
if _, err := UploadStream(ctx, c, strings.NewReader("%PDF-1.4"), "doc.pdf", "application/pdf"); err != nil {
t.Fatalf("UploadStream: %v", err)
}
})
}
}
12 changes: 6 additions & 6 deletions internal/extendx/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ func UploadFileWithOptions(ctx context.Context, c *client.Client, path string, o
// wrap it with the filename and content-type metadata that the server
// uses for type inference.
//
// We pass UploadOption() so the call uses an untimed *http.Client.
// The CLI's default --http-timeout (60s) is hostile to large multipart
// bodies on slow connections — a legitimate 100MB upload would surface
// as a vague "context deadline exceeded" instead of completing. The
// caller's context remains the only deadline.
// The upload runs with a withUpload context so it uses an untimed
// *http.Client. The CLI's default --http-timeout (60s) is hostile to
// large multipart bodies on slow connections — a legitimate 100MB
// upload would surface as a vague "context deadline exceeded" instead
// of completing. The caller's context remains the only deadline.
func UploadStream(ctx context.Context, c *client.Client, body io.Reader, filename, contentType string) (*extend.File, error) {
return UploadStreamWithOptions(ctx, c, body, filename, contentType, UploadOptions{})
}
Expand All @@ -130,7 +130,7 @@ func UploadStreamWithOptions(ctx context.Context, c *client.Client, body io.Read
if filename != "" || contentType != "" {
reader = extend.NewFileParam(body, filename, contentType)
}
return c.Files.Upload(ctx, reader, req, UploadOption())
return c.Files.Upload(withUpload(ctx), reader, req)
}

// DownloadFile fetches a file's contents by ID. It calls the SDK's
Expand Down
Loading