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
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ go-sdk/
├── pipeline/ # Policy, PolicyFunc, Transporter, Request, Pipeline
├── transport/ # default net/http Transporter
├── retry/ # retry policy (backoff + jitter + Retry-After)
├── idempotency/ # idempotency-key policy (default-on for POST)
├── auth/ # TokenCredential, BearerTokenPolicy, StaticToken
├── logging/ # slog request/response policy
├── httperr/ # ResponseError + FromResponse
Expand Down Expand Up @@ -125,7 +126,7 @@ terminating in a `Transporter`:
2. **`transport`** — wraps an `*http.Client` (cloned `http.DefaultTransport`
with larger idle-conn limits) to satisfy `Transporter`.
3. **Policies** — `retry`, `auth`, `logging`, each a `Policy`. Order is set by
`dexpace.New`: `user-agent → idempotency → retry → auth → date → [tracing] → [metrics] → logging → custom → transport`.
`dexpace.New`: `[errors] → user-agent → idempotency → retry → auth → [date] → [tracing] → [metrics] → logging → custom → transport`.
4. **Value layer** — `mediatype`, `header`, `httperr`, `pagination`: small,
stdlib-only helpers over `net/http`.

Expand All @@ -135,9 +136,10 @@ terminating in a `Transporter`:
automatically for `bytes.Reader`/`bytes.Buffer`/`strings.Reader` bodies. A
streaming body (`io.Reader` with no `GetBody`) is **not** replayable — rewind
returns an error and retries fail. Buffer such bodies before sending.
- **`BearerTokenPolicy` is HTTPS-only.** It returns `auth.ErrInsecureTransport`
for a non-`https` URL rather than leaking a token. Tests must use `https://`
URLs (a stub transporter never dials).
- **The credential policies are HTTPS-only.** `BearerTokenPolicy`,
`BasicAuthPolicy`, and `APIKeyPolicy` all return `auth.ErrInsecureTransport`
for a non-`https` URL rather than leaking a credential. Tests must use
`https://` URLs (a stub transporter never dials).
- **Policy order changes semantics.** Retry is outside auth, so a 401-triggered
token refresh requires the auth policy to be inside retry (it is, by default).
Moving logging outside retry collapses per-attempt logs into one.
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ standard library.
`dexpace.New` assembles policies outermost-first:

```
user-agent → idempotency → retry → auth → date → [tracing] → [metrics] → logging → custom → transport
[errors] → client-identity → idempotency → retry → auth → [date] → [tracing] → [metrics] → logging → custom → transport
```

Retry wraps the inner policies, so auth re-runs (and may refresh its token) on
Expand All @@ -96,21 +96,26 @@ wire a backend):
via the instrumentation `Tracer` SPI and injects a W3C `traceparent` header.
- `WithMetrics(meter)` — installs a metrics policy recording request duration and
in-flight requests via the instrumentation `Meter` SPI.
- `WithRedactionAllowlist(params...)` — preserves the listed query-param values in
redacted URLs (logs and traces); all other query values are redacted by default.

URLs are redacted by default across logs, traces, and errors: userinfo is
stripped and query values are redacted unless allowlisted with
`WithRedactionAllowlist`.

### Authentication and configuration

- `WithCredential(cred, scopes...)` — authenticates requests with bearer tokens
from a `TokenCredential` (HTTPS-only, cached).
- `WithTokenCache(cache)` — shares a bearer-token cache (an `auth.TokenCache`, in-memory
by default) across clients so a cached token is reused.
- `WithBasicAuth(username, password)` — authenticates requests with HTTP Basic auth (HTTPS-only).
- `WithAPIKey(header, key)` — sets an API-key header on every request (HTTPS-only).
- `WithRedactionAllowlist(params...)` — preserves the listed query-param values in
redacted URLs (logs and traces); all other query values are redacted by default.
- `WithConfig(cfg)` — sources defaults from `DEXPACE_*` environment variables —
`DEXPACE_USER_AGENT`, `DEXPACE_MAX_RETRIES` (0 or negative disables retries),
`DEXPACE_RETRY_BASE_DELAY`, `DEXPACE_HTTP_TIMEOUT` (default transport only) — for
settings not set explicitly; explicit options always win.

URLs are redacted by default across logs, traces, and errors: userinfo is
stripped and query values are redacted unless allowlisted with
`WithRedactionAllowlist`.

## Requirements

Go **1.26+**. The module targets modern idioms: generics, range-over-func
Expand Down
2 changes: 1 addition & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type Client struct {
// New assembles a Client. Built-in policies are placed in stage order, outermost
// first:
//
// client-identity → idempotency → retry → auth → date → [tracing] → [metrics] → logging → transport
// [errors] → client-identity → idempotency → retry → auth → [date] → [tracing] → [metrics] → logging → transport
//
// When WithErrors is supplied, an errors stage is prepended as the outermost
// policy, mapping the final result to the typed error model.
Expand Down
15 changes: 12 additions & 3 deletions httperr/transport_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"net"
"net/http"
"net/url"

"github.com/dexpace/go-sdk/redact"
)
Expand All @@ -28,12 +29,20 @@ type TransportError struct {
Err error
}

// Error implements error.
// Error implements error. When the underlying cause is a *url.Error (as produced
// by net/http), its message — which embeds the full, unredacted URL — is replaced
// by the inner cause, so a query secret in the URL never reaches the error string.
// The redacted URL is reported via the Method/URL fields instead.
func (e *TransportError) Error() string {
cause := e.Err
var ue *url.Error
if errors.As(e.Err, &ue) {
cause = ue.Err
}
if e.URL == "" {
return fmt.Sprintf("transport error: %v", e.Err)
return fmt.Sprintf("transport error: %v", cause)
}
return fmt.Sprintf("transport error: %s %s: %v", e.Method, e.URL, e.Err)
return fmt.Sprintf("transport error: %s %s: %v", e.Method, e.URL, cause)
}

// Unwrap returns the underlying cause so errors.Is/As reach through it.
Expand Down
30 changes: 30 additions & 0 deletions httperr/transport_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"net/url"
"strings"
"testing"

"github.com/dexpace/go-sdk/httperr"
Expand Down Expand Up @@ -71,6 +72,35 @@ func TestFromErrorWrapsTransportFailure(t *testing.T) {
}
}

func TestTransportErrorDoesNotLeakURLInMessage(t *testing.T) {
t.Parallel()

// net/http surfaces a *url.Error whose Error() embeds the full raw URL,
// including query secrets. TransportError.Error() must not reproduce it.
cause := &url.Error{
Op: "Get",
URL: "https://api.example.test/things?token=SECRET",
Err: errors.New("dial tcp: connection refused"),
}
req := &http.Request{
Method: http.MethodGet,
URL: &url.URL{Scheme: "https", Host: "api.example.test", Path: "/things", RawQuery: "token=SECRET"},
}
te := httperr.FromError(cause, req)

msg := te.Error()
if strings.Contains(msg, "SECRET") {
t.Fatalf("Error() leaked the query secret: %q", msg)
}
if !strings.Contains(msg, "connection refused") {
t.Fatalf("Error() should include the underlying cause: %q", msg)
}
// Unwrap must remain lossless: the original cause is still reachable.
if !errors.Is(te, cause) {
t.Fatal("Unwrap must still reach the original *url.Error cause")
}
}

func TestTransportErrorTimeout(t *testing.T) {
t.Parallel()

Expand Down
28 changes: 27 additions & 1 deletion jsonl/jsonl.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,38 @@ import (
"iter"
)

// Option configures [Decode].
type Option func(*config)

type config struct {
maxBytes int64
}

// WithMaxBytes bounds the total number of bytes Decode reads from the stream, so
// an untrusted source cannot force unbounded memory use. A value <= 0 (the
// default) leaves the stream unbounded. When the limit is reached mid-value, the
// truncated value surfaces a decode error.
func WithMaxBytes(n int64) Option {
return func(c *config) { c.maxBytes = n }
}

// Decode reads a stream of JSON values from r and yields each decoded into a T.
// Values may be separated by any JSON whitespace (newlines for NDJSON / JSON
// Lines, or none). Iteration ends at end of stream; a decode error is delivered
// as the second iteration value, after which iteration stops. The iterator is
// single-pass.
func Decode[T any](r io.Reader) iter.Seq2[T, error] {
//
// Decode does not bound the size of an individual JSON value by default; for an
// untrusted stream, pass [WithMaxBytes] (or wrap r in an io.LimitReader) so a
// hostile value cannot exhaust memory.
func Decode[T any](r io.Reader, opts ...Option) iter.Seq2[T, error] {
var cfg config
for _, opt := range opts {
opt(&cfg)
}
if cfg.maxBytes > 0 {
r = io.LimitReader(r, cfg.maxBytes)
}
return func(yield func(T, error) bool) {
dec := json.NewDecoder(r)
for {
Expand Down
36 changes: 36 additions & 0 deletions jsonl/jsonl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,39 @@ func TestDecodeEarlyBreak(t *testing.T) {
t.Fatalf("consumed %d values, want 1 after break", count)
}
}

func TestDecodeWithMaxBytes(t *testing.T) {
t.Parallel()

// Two small values fit under the cap.
var got []rec
for v, err := range jsonl.Decode[rec](strings.NewReader("{\"n\":1}\n{\"n\":2}\n"), jsonl.WithMaxBytes(64)) {
if err != nil {
t.Fatalf("unexpected error under cap: %v", err)
}
got = append(got, v)
}
if len(got) != 2 {
t.Fatalf("got %d values under cap, want 2", len(got))
}
}

func TestDecodeMaxBytesTruncatesOversized(t *testing.T) {
t.Parallel()

// A single value larger than the cap: the read is bounded, so decoding the
// truncated input yields an error rather than reading unbounded memory.
big := "{\"s\":\"" + strings.Repeat("a", 200) + "\"}"
var got int
var gotErr error
for _, err := range jsonl.Decode[map[string]any](strings.NewReader(big), jsonl.WithMaxBytes(32)) {
if err != nil {
gotErr = err
break
}
got++
}
if gotErr == nil {
t.Fatalf("expected an error when a value exceeds the byte cap (got %d values)", got)
}
}
3 changes: 3 additions & 0 deletions pagination/strategies.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ func NewPageNumber[T any](startPage int, fetch func(ctx context.Context, page in
// NewLinkHeader returns a Pager that follows RFC 8288 Link headers. fetch is
// called with the next URL (empty for the first page) and returns the page's
// items and the HTTP response whose Link header carries the next URL.
//
// The next URL is taken from the server-controlled Link header, so fetch should
// validate or trust the URL's host before dialing it to avoid SSRF.
func NewLinkHeader[T any](fetch func(ctx context.Context, url string) ([]T, *http.Response, error), opts ...Option) *Pager[T] {
tokenFetch := func(ctx context.Context, token string) (Page[T], error) {
items, resp, err := fetch(ctx, token)
Expand Down
Loading