-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathghkit.go
More file actions
187 lines (167 loc) · 6 KB
/
Copy pathghkit.go
File metadata and controls
187 lines (167 loc) · 6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
package ghkit
import (
"cmp"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/pcanilho/go-github-kit/etag"
"github.com/pcanilho/go-github-kit/ratelimit"
"github.com/pcanilho/go-github-kit/retry"
"github.com/pcanilho/go-github-kit/throttle"
"golang.org/x/oauth2"
)
// Sentinel errors for config validation. Callers can use errors.Is to
// distinguish specific failure modes in tests or runtime handling.
var (
ErrConflictingAuth = errors.New("ghkit: WithToken and WithTokenSource are mutually exclusive")
ErrConflictingRateLimit = errors.New("ghkit: WithRateLimit and WithRateLimitDisabled are mutually exclusive")
ErrPreAuthedBaseWithAuth = errors.New("ghkit: WithBaseTransport with a non-*http.Transport base cannot be combined with WithToken or WithTokenSource")
ErrNonPositiveRPS = errors.New("ghkit: WithRequestsPerSecond requires rps > 0 and burst >= 1")
ErrNilFactory = errors.New("ghkit: New requires a non-nil factory function")
)
// HTTPClient builds an *http.Client with the configured transport stack.
// Returns an error when the option combination is invalid; see the sentinel
// errors above.
func HTTPClient(opts ...Option) (*http.Client, error) {
cfg := newConfig()
for _, o := range opts {
o.apply(cfg)
}
if err := validateConfig(cfg); err != nil {
return nil, err
}
// Silent by default: no logger means no log output. Sub-packages also
// receive this (possibly discarding) logger, but per-sub-package
// WithLogger options can still override since user opts apply after
// our defaulted one (see prepend pattern below).
cfg.logger = cmp.Or(cfg.logger, slog.New(slog.DiscardHandler))
// Build inside-out: base -> etag -> oauth2 -> retry -> throttle -> ratelimit -> userAgent.
// ETag below oauth2: hash domain sees the cloned Authorization header.
// RateLimit above throttle: secondary cooldown parks new arrivals before
// they consume throttle tokens. UserAgent outermost: overrides any
// SDK-level value.
rt := cfg.baseTransport
if cfg.etagEnabled {
etagOpts := append([]etag.Option{etag.WithLogger(cfg.logger)}, cfg.etagOpts...)
inner, err := etag.NewTransport(rt, etagOpts...)
if err != nil {
return nil, fmt.Errorf("ghkit: etag: %w", err)
}
rt = inner
}
if cfg.token != "" || cfg.tokenSource != nil {
src := cfg.tokenSource
if cfg.token != "" {
src = oauth2.StaticTokenSource(&oauth2.Token{AccessToken: cfg.token})
}
rt = &oauth2.Transport{Source: src, Base: rt}
} else if rt == nil {
rt = http.DefaultTransport
}
if cfg.retryEnabled {
retryOpts := append([]retry.Option{retry.WithLogger(cfg.logger)}, cfg.retryOpts...)
inner, err := retry.NewTransport(rt, retryOpts...)
if err != nil {
return nil, fmt.Errorf("ghkit: retry: %w", err)
}
rt = inner
}
if cfg.throttleRPS > 0 {
thr, err := throttle.NewTransport(rt, cfg.throttleRPS, throttle.WithBurst(cfg.throttleBurst))
if err != nil {
return nil, fmt.Errorf("ghkit: throttle: %w", err)
}
rt = thr
}
if cfg.rateLimitEnabled {
rlOpts := append([]ratelimit.Option{ratelimit.WithLogger(cfg.logger)}, cfg.rateLimitOpts...)
rt = ratelimit.NewTransport(rt, rlOpts...)
}
if cfg.userAgent != "" {
rt = &userAgentTransport{base: rt, ua: cfg.userAgent}
}
hc := &http.Client{Transport: rt}
if cfg.timeout > 0 {
hc.Timeout = cfg.timeout
}
return hc, nil
}
// userAgentTransport sets a fixed User-Agent header on every request. It
// clones the request before mutating (the http.RoundTripper contract
// forbids modifying the caller's request).
type userAgentTransport struct {
base http.RoundTripper
ua string
}
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
cloned := req.Clone(req.Context())
cloned.Header.Set("User-Agent", t.ua)
return t.base.RoundTrip(cloned)
}
// New builds an *http.Client via HTTPClient and plumbs it into the
// caller-supplied factory. Generic over the returned type so ghkit has no
// compile-time dependency on any specific GitHub SDK; pass whichever
// constructor you use at the call site.
//
// Canonical usage:
//
// import "github.com/google/go-github/v85/github"
//
// gh, err := ghkit.New(github.NewClient,
// ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
// ghkit.WithETagCache(),
// )
//
// Custom construction (UserAgent, GitHub Enterprise BaseURL, any other
// post-construction tweaks) goes inside a factory closure:
//
// gh, err := ghkit.New(func(hc *http.Client) *github.Client {
// c := github.NewClient(hc)
// c.UserAgent = "my-app/1.0"
// return c
// }, opts...)
//
// For GitHub Enterprise, call github.NewClient(hc).WithEnterpriseURLs(base, upload)
// inside the closure. The base URL must end with a trailing slash; go-github
// returns an error if it does not.
//
// When factory is nil, New returns the zero value of T and ErrNilFactory.
// When HTTPClient returns an error (invalid option combination), New
// proxies the error.
func New[T any](factory func(*http.Client) T, opts ...Option) (T, error) {
var zero T
if factory == nil {
return zero, ErrNilFactory
}
hc, err := HTTPClient(opts...)
if err != nil {
return zero, err
}
return factory(hc), nil
}
func validateConfig(c *config) error {
if c.token != "" && c.tokenSource != nil {
return ErrConflictingAuth
}
// WithRateLimit and WithRateLimitDisabled together is a contradiction:
// the user registered callbacks while explicitly turning the layer off.
if c.rateLimitDisabledByUser && len(c.rateLimitOpts) > 0 {
return ErrConflictingRateLimit
}
// Wrap with the concrete base type so the error tells the caller what
// they actually passed.
if c.baseTransport != nil && (c.token != "" || c.tokenSource != nil) {
if _, ok := c.baseTransport.(*http.Transport); !ok {
return fmt.Errorf("%w: WithBaseTransport was %T", ErrPreAuthedBaseWithAuth, c.baseTransport)
}
}
// Both fields at zero means WithRequestsPerSecond was never called; only
// validate when the caller opted in.
if c.throttleRPS != 0 || c.throttleBurst != 0 {
if c.throttleRPS <= 0 || c.throttleBurst < 1 {
return ErrNonPositiveRPS
}
}
return nil
}