Reuse a connection across components¶
A provider is built per component, not per host. Two parts of your program that both talk to the same forge each construct one, so each opens its own connection pool, completes its own TLS handshake to a host you are already talking to, and re-resolves your credential, which may reach a keychain or a remote secret store.
They then use capabilities of what is, as far as the forge is concerned, the same connection.
There are two ways to stop that, and they solve different halves.
Share the provider¶
A pool stops you re-building a provider, and therefore stops you re-dialling and re-resolving the credential, because the client and the credential both belong to the provider you are reusing.
This is the change to make.
import "gitlab.com/phpboyscout/go/forge/pool"
p := pool.New(cfg)
releases := p.Source(forge.Endpoint{Type: forge.SourceTypeGitLab})
A pool is a value you construct, hold and pass. It is never package state, so sharing is visible in your wiring and a component you hand nothing shares nothing.
Give a component the source, not the provider¶
// declared by the consumer, satisfied by pool.Source(ep)
type providerSource interface {
Provider(ctx context.Context) (forge.Provider, error)
}
func NewUpdater(src providerSource) *Updater { … }
Hand over the accessor, not a resolved provider. Resolving in your wiring code would choose when resolution happens (and therefore when it fails) for every component at once. An accessor lets each decide.
Declare the interface in the consuming package rather than importing pool for a
type. A component with no pool to hand can then be given a closure over
forge.Lookup satisfying the same interface, so the zero-conf path stays a
fallback rather than a second code path through your component:
type lookupSource struct{ endpoint forge.Endpoint }
func (l lookupSource) Provider(ctx context.Context) (forge.Provider, error) {
factory, err := forge.Lookup(l.endpoint.Type)
if err != nil {
return nil, err
}
return factory(ctx, l.endpoint, nil)
}
Fail at your own boundary, if you want to¶
Resolution is lazy: the first Provider call builds. That moves a persistent
misconfiguration (an unset credential, an unreachable host) from start-up to
first use, so an operator running several operations sees it fail one operation
in, from something that appeared to have started cleanly.
A command that would rather fail at its boundary asks:
Nothing requires it, and the lazy path is unchanged for callers that prefer resolution to happen when the work does.
Scope a pool to a unit of work¶
A pooled provider holds a frozen credential
Every adapter here resolves its credential when the provider is built, and holds the resulting string. Nothing refreshes it and nothing can, because your credential source may return a token with an expiry, and the provider has no way to learn it has lapsed.
This is unlike a cloud SDK configuration, which refreshes underneath its holder and is therefore safe to hold indefinitely.
So reuse extends a captured credential's life from one component's work to the pool's. A pool belongs to a unit of work: a command for a CLI, one cycle for a daemon.
A fresh pool per cycle is correct rather than wasteful. The transport is what carries connection reuse across cycles, and a transport holds no credential, so build the transport once, at start-up, and let the pool be short-lived:
transport := httpclient.NewClient().Transport // once, for the process
for range cycles {
p := pool.New(cfg, forge.WithHTTPTransport(transport)) // once, per cycle
…
}
Two sources of one forge¶
Two credentials for one host, or two instances of one provider, are two endpoints, and they must be, or they share a memo:
work := forge.Endpoint{Type: forge.SourceTypeGitLab, Name: "work"}
oss := forge.Endpoint{Type: forge.SourceTypeGitLab}
Name also scopes the configuration each reads (gitlab.work and gitlab
respectively) so they resolve their own credentials and their own endpoint
overrides. See Configuration.
If your two sources are not separated by Name, use two pools. Separate
pools, separate memos, nothing shared by accident.
Invalidating a lapsed credential¶
p.Invalidate(endpoint) discards the provider held for an endpoint, so the next
request rebuilds and re-resolves.
Invalidate only on asserted credential-invalidity
Not on "the operation failed". Invalidation creates a new resolution generation, so invalidating on the wrong signal does not merely retry. It fans concurrent credential resolutions at a forge that is already refusing.
The deciding property is not whether the failure looks transient:
| The error asserts | Invalidate? |
|---|---|
| this credential is no longer valid | yes |
| the credential is valid, you lack permission | no, it would loop |
| you are rate limited | no, back off |
| "the call failed" | no |
A rate limit is transient, and invalidating on one aims concurrent resolutions at an API already throttling you, which is how a throttle becomes a lockout.
The sentinel that answers this is forge.ErrUnauthorized, and it is the only
one of the refusals that authorises invalidating:
forge.ErrForbidden and forge.ErrRateLimited must not trigger it, for the
reasons in the table above. See the errors
reference for what each one
authorises.
Share the transport¶
There is a second rung that shares only the connection pool, leaving each provider to build its own client and keep its own redirect and sensitive-header policy:
A pool already reuses the whole provider, and therefore its client, so reach for this when you have providers a pool does not cover, or want one pool shared with the rest of your program's HTTP traffic.
The distinction between the two rungs is a security question rather than a
preference. WithHTTPTransport shares the expensive part (the connection pool
and TLS session cache both live in the transport) while each
provider keeps its own redirect policy. WithHTTPClient hands that policy over
with the client: a provider that attaches a credential by hand relies on it to
stop the credential following a redirect off the host it pinned, and GitLab's
PRIVATE-TOKEN is that case here.
Hand over the client you already built¶
If you already hold the platform SDK's own client (a GitHub App installation transport, a rotating token source, an enterprise proxy) hand it over directly:
Offered by forge-github, forge-gitlab and forge-gitea. Not by
forge-bitbucket or direct, which have no platform SDK. Their native unit is
the *http.Client, so the rung would duplicate WithHTTPClient.
This rung transfers the credential obligation
The client carries its own authentication and the provider adds none, so a
Settings.Credential alongside it is an error rather than a silent
preference.
The consequence differs by forge, and it is worth knowing before choosing this rung:
| asset downloads | |
|---|---|
forge-github |
stay authenticated. The download rides the SDK client, which stops at the redirect and follows it credential-free |
forge-gitlab, forge-gitea |
anonymous. An asset there is an author-supplied URL the SDK never fetches, and neither SDK exposes its credential |
So a private asset resolves on GitHub and fails on the other two. If you need
authenticated downloads there, use a lower rung (a transport, or Settings
with a Credential) where the provider still owns the credential and the
redirect policy that keeps it on the pinned host.
forge-gitea also needs Endpoint.Host set: its SDK keeps the
instance URL unexported, so unlike the other two it cannot be read back off
the client.
What it costs¶
forge/pool adds one module, gitlab.com/phpboyscout/go/clientlifecycle, which
has no dependencies of its own. It is a separate package from the contract so
that anyone merely authoring or accepting a Provider pays nothing for machinery
they do not use; depfootprint_test.go asserts exactly that.
Related¶
- Configuration: the two connection rungs, and what each transfers
- Author a provider: how a provider honours them
- Authenticate to a forge: where credentials come from in the first place