Skip to content

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:

if err := p.Resolve(ctx, endpoints...); err != nil {
    return err
}

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:

if errors.Is(err, forge.ErrUnauthorized) {
    p.Invalidate(endpoint)
}

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:

provider, err := factory(ctx, endpoint, cfg, forge.WithHTTPTransport(transport))

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:

provider, err := github.NewProviderFromClient(ctx, ghClient, github.Settings{})

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.