Skip to content

Commit 4288e20

Browse files
committed
refactor(planfix): route all requests through Client.Proxy
Previously only Stream read c.Proxy; Do/JSON got proxying from the default transport, which reads the environment independently. Setting c.Proxy would have governed downloads but not other requests -- an inconsistent knob. New now clones the standard transport (keeping its connection-pool defaults) for the shared Do/JSON client and points its Proxy at c.Proxy, resolved per request; Stream uses the same helper. A single field now governs proxying for every request, and nil disables it.
1 parent 6cb5fb8 commit 4288e20

3 files changed

Lines changed: 58 additions & 15 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Implemented:
9696
- Precedence: command-line flags > environment (`PFIX_DOMAIN`, `PFIX_TOKEN`, `PFIX_PROFILE`, `PFIX_CONFIG`) > config file. Profile name resolves through `config.ResolveProfileName` (`flag > PFIX_PROFILE > current_profile > "default"`) — use it everywhere a command needs the active profile, so the commands stay consistent.
9797
- Output: typed `task` commands default to a human-readable table (list) or key/value detail (single object), rendered by `internal/output` (stdlib `text/tabwriter`, no color). `--json` emits the API response unmodified (pretty-printed); `--fields` overrides the requested fields and table columns; `-q/--quiet` drops the header row (lists) or prints only the affected id (create/update/comment add). `--jq <expr>` filters the JSON output through a jq expression (implies `--json`). `api` always emits raw JSON. Errors go to stderr with a non-zero exit code. The Planfix layer stays thin — commands render generically from decoded `map[string]any` via dot-paths rather than typed structs, so unconfirmed nested shapes need no model.
9898
- Transport: `Client.Do` returns the HTTP response for any status (callers inspect `StatusCode` and use `planfix.ParseError` for detail). It retries connection errors + 5xx, never 4xx. Every request carries a `User-Agent` of `pfix/<version>` (from `buildinfo.Version`, set on the `Client.UserAgent` field in `New`); a caller-supplied `User-Agent` header — e.g. `api -H "User-Agent: ..."` — overrides it.
99-
- Proxy: the client follows the standard Go proxy environment variables (`HTTP(S)_PROXY`/`NO_PROXY`). `Do`/`JSON` inherit them via the default transport; `Stream` (file download) builds its own `http.Transport`, so it must set the proxy explicitly — it uses `Proxy: c.Proxy`, whose `New` default is `http.ProxyFromEnvironment` (a bare transport would disable proxying, so downloads would ignore the env and — where the only egress is a proxy — fail outright). `ALL_PROXY` is not honored (the Go stdlib `httpproxy` package does not read it).
99+
- Proxy: the client follows the standard Go proxy environment variables (`HTTP(S)_PROXY`/`NO_PROXY`) for every request. Both HTTP paths resolve the proxy through the `Client.Proxy` field (default `http.ProxyFromEnvironment`, set in `New`): `New` clones the standard transport for the shared `Do`/`JSON` client, and `Stream` (file download) builds its own timeout-free transport — both point `Proxy` at `c.Proxy`, resolved per request, so overriding the field (or nil-ing it to force a direct connection) governs all requests. A bare `http.Transport` would instead disable proxying outright, which is why `Stream` must not use one. `ALL_PROXY` is not honored (the Go stdlib `httpproxy` package does not read it).
100100
- API specifics: list endpoints are POST with `pageSize`/`offset`/`fields`/`filters`; fields must be requested explicitly — ship sensible per-resource defaults, overridable with `--fields`.
101101
- Files: `task files`/`contact files`/`project files --source inline` *composes* its JSON output (`{"result":"success","files":[...]}` built from resolved ids) rather than echoing an API response — there is no single endpoint for inline files, so `--json`/`--jq` there reflect pfix's aggregation, not one call's response body.
102102
- Files: `--fields` on the files commands (`task files`, `contact files`, `project files`) selects table columns only. No file-listing endpoint supports server-side field selection — `/file/{id}`'s own `fields` parameter is accepted and silently ignored — so unlike other typed commands, `--fields` there never changes what the API is asked for.

internal/planfix/client.go

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,43 @@ type Client struct {
2525
Retries int
2626
Backoff func(attempt int) time.Duration
2727
UserAgent string // sent as the User-Agent header unless a caller overrides it
28-
// Proxy selects the proxy for Stream (file downloads); default
29-
// http.ProxyFromEnvironment. Do/JSON go through HTTP, whose default
30-
// transport reads the same environment variables independently.
28+
// Proxy selects the proxy for every request; default
29+
// http.ProxyFromEnvironment. Both the shared HTTP client and Stream's
30+
// timeout-free client resolve it through this field at request time, so
31+
// overriding it (or setting it to nil to disable proxying) governs all
32+
// requests.
3133
Proxy func(*http.Request) (*url.URL, error)
3234
}
3335

3436
// New returns a Client with sane defaults (~5 req/s, 3 attempts).
3537
func New(domain, token string) *Client {
36-
return &Client{
38+
c := &Client{
3739
Domain: domain,
3840
Token: token,
39-
HTTP: &http.Client{Timeout: 30 * time.Second},
4041
Limiter: rate.NewLimiter(rate.Limit(5), 1),
4142
Retries: 3,
4243
Backoff: defaultBackoff,
4344
UserAgent: "pfix/" + buildinfo.Version,
4445
Proxy: http.ProxyFromEnvironment,
4546
}
47+
c.HTTP = &http.Client{Timeout: 30 * time.Second, Transport: c.newTransport(0)}
48+
return c
49+
}
50+
51+
// newTransport builds an HTTP transport that resolves its proxy through c.Proxy
52+
// at request time — so overriding c.Proxy governs every request — and clones the
53+
// standard transport for its connection-pool defaults. respHeaderTimeout caps
54+
// the wait for a response's headers (0 disables it).
55+
func (c *Client) newTransport(respHeaderTimeout time.Duration) *http.Transport {
56+
t := http.DefaultTransport.(*http.Transport).Clone()
57+
t.Proxy = func(req *http.Request) (*url.URL, error) {
58+
if c.Proxy == nil {
59+
return nil, nil
60+
}
61+
return c.Proxy(req)
62+
}
63+
t.ResponseHeaderTimeout = respHeaderTimeout
64+
return t
4665
}
4766

4867
func defaultBackoff(attempt int) time.Duration {
@@ -70,16 +89,12 @@ func (c *Client) Do(ctx context.Context, method, path string, body []byte, heade
7089
// Stream sends an authenticated GET and returns the response with its Body
7190
// unread; the caller must close it. Unlike Do/JSON it runs against a client
7291
// with no whole-request timeout (only a 30s response-header timeout), so
73-
// reading a large body is not cut off by a deadline. Its transport takes its
74-
// proxy from c.Proxy (default http.ProxyFromEnvironment), so downloads honor
75-
// HTTP(S)_PROXY/NO_PROXY like the default client — a bare http.Transport would
76-
// disable proxying. Redirects to object storage are followed by net/http, which
77-
// drops the Authorization header on the cross-host hop.
92+
// reading a large body is not cut off by a deadline. It shares Do's proxy
93+
// handling via c.Proxy (see the Proxy field). Redirects to object storage are
94+
// followed by net/http, which drops the Authorization header on the cross-host
95+
// hop.
7896
func (c *Client) Stream(ctx context.Context, path string) (*http.Response, error) {
79-
hc := &http.Client{Transport: &http.Transport{
80-
Proxy: c.Proxy,
81-
ResponseHeaderTimeout: 30 * time.Second,
82-
}}
97+
hc := &http.Client{Transport: c.newTransport(30 * time.Second)}
8398
return c.do(ctx, http.MethodGet, path, nil, nil, hc)
8499
}
85100

internal/planfix/client_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,34 @@ func TestDoDoesNotRetryOn4xx(t *testing.T) {
174174
}
175175
}
176176

177+
func TestDoRoutesThroughProxy(t *testing.T) {
178+
var proxied int32
179+
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180+
atomic.AddInt32(&proxied, 1)
181+
io.WriteString(w, `{"ok":true}`)
182+
}))
183+
defer proxy.Close()
184+
185+
proxyURL, err := url.Parse(proxy.URL)
186+
if err != nil {
187+
t.Fatalf("parse proxy URL: %v", err)
188+
}
189+
190+
// Non-routable base: a success proves Do reaches the origin only via the
191+
// proxy, i.e. that c.Proxy governs Do (not just Stream).
192+
c := fastClient("http://origin.invalid/rest")
193+
c.Proxy = func(*http.Request) (*url.URL, error) { return proxyURL, nil }
194+
195+
resp, err := c.Do(context.Background(), "GET", "task/1", nil, nil)
196+
if err != nil {
197+
t.Fatalf("Do: %v", err)
198+
}
199+
resp.Body.Close()
200+
if got := atomic.LoadInt32(&proxied); got != 1 {
201+
t.Fatalf("proxy calls = %d, want 1", got)
202+
}
203+
}
204+
177205
func TestStreamRoutesThroughProxy(t *testing.T) {
178206
var proxied int32
179207
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)