Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/conan.go
Giles Westwood b2955ef458 Keep holds through late successes and away from the circuit breaker
A response arriving during a hold was sent before it began, so a late
success no longer releases the host; only one after the hold has ended
does.

Artifact fetches check RateLimitGate.Held before calling the fetcher, so
held cache misses fail with the HeldError without counting as failures
in the host's circuit breaker.

Every handler that answers an upstream fetch error with 502 now answers
a held request with 503 and Retry-After instead.

OnHold and RecordUpstreamRateLimitHold drop the unused duration.
2026-10-15 13:35:12 +01:00

209 lines
6.8 KiB
Go

package handler
import (
"net/http"
"strings"
"github.com/git-pkgs/proxy/internal/retention"
)
const (
conanUpstream = "https://center.conan.io"
)
// Recipe and package archives are stored under pkg:conan/{name}, with the
// version as the version and the revisions and any user/channel in the
// filename, and purl keeps conan names as they are. A package rule therefore
// covers every version, revision and user/channel of a recipe, and uses the
// default key mapping.
func init() {
retention.Default.Register(retention.Spec{Key: "conan", CanonicalPackage: retention.DefaultCanonical("conan")})
}
// ConanHandler handles Conan registry protocol requests.
type ConanHandler struct {
proxy *Proxy
upstreamURL string
proxyURL string
}
// NewConanHandler creates a new Conan protocol handler.
func NewConanHandler(proxy *Proxy, proxyURL string) *ConanHandler {
return &ConanHandler{
proxy: proxy,
upstreamURL: conanUpstream,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
// NewConanHandlerWithUpstream creates a Conan handler with a custom upstream.
func NewConanHandlerWithUpstream(proxy *Proxy, proxyURL, upstreamURL string) *ConanHandler {
h := NewConanHandler(proxy, proxyURL)
h.upstreamURL = configuredUpstreamURL(upstreamURL, conanUpstream)
return h
}
// Routes returns the HTTP handler for Conan requests.
func (h *ConanHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Ping endpoint
mux.HandleFunc("GET /v1/ping", h.handlePing)
mux.HandleFunc("GET /v2/ping", h.handlePing)
// Recipe and package file downloads, as built by the Conan 2 client
// (ClientV2Router.recipe_file and package_file). The v1 protocol hands out
// signed absolute URLs on the upstream host, so its downloads never pass
// through here.
mux.HandleFunc("GET /v2/conans/{name}/{version}/{user}/{channel}/revisions/{revision}/files/{filename}", h.handleRecipeFile)
mux.HandleFunc("GET /v2/conans/{name}/{version}/{user}/{channel}/revisions/{revision}/packages/{pkgref}/revisions/{pkgrev}/files/{filename}", h.handlePackageFile)
// Proxy all other endpoints (metadata, search, etc.) with caching
mux.HandleFunc("GET /", h.proxyCached)
return mux
}
// handlePing responds to Conan ping requests.
func (h *ConanHandler) handlePing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Conan-Server-Capabilities", "revisions")
w.WriteHeader(http.StatusOK)
}
// handleRecipeFile serves a recipe file, fetching and caching from upstream if needed.
func (h *ConanHandler) handleRecipeFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
version := r.PathValue("version")
user := r.PathValue("user")
channel := r.PathValue("channel")
revision := r.PathValue("revision")
filename := r.PathValue("filename")
// Only the archives are artifacts; the rest goes through the metadata cache.
if !h.shouldCacheFile(filename) {
h.proxyCached(w, r)
return
}
h.proxy.Logger.Info("conan recipe download",
"name", name, "version", version, "user", user, "channel", channel, "filename", filename)
upstreamURL := h.upstreamURL + r.URL.Path
storageFilename := conanStorageFilename(user, channel, revision+"_"+filename)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", name, version, storageFilename, upstreamURL)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch file")
return
}
ServeArtifactRequest(w, r, result)
}
// handlePackageFile serves a package file, fetching and caching from upstream if needed.
func (h *ConanHandler) handlePackageFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
version := r.PathValue("version")
user := r.PathValue("user")
channel := r.PathValue("channel")
revision := r.PathValue("revision")
pkgref := r.PathValue("pkgref")
pkgrev := r.PathValue("pkgrev")
filename := r.PathValue("filename")
// Only the archives are artifacts; the rest goes through the metadata cache.
if !h.shouldCacheFile(filename) {
h.proxyCached(w, r)
return
}
h.proxy.Logger.Info("conan package download",
"name", name, "version", version, "pkgref", pkgref, "filename", filename)
upstreamURL := h.upstreamURL + r.URL.Path
storageFilename := conanStorageFilename(user, channel, revision+"_"+pkgref+"_"+pkgrev+"_"+filename)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conan", name, version, storageFilename, upstreamURL)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch file")
return
}
ServeArtifactRequest(w, r, result)
}
// conanStorageFilename names a cached archive. Archives are stored under
// pkg:conan/{name}@{version}, so the file name carries the revisions and, when
// set, user and channel, to keep every recipe and binary apart.
func conanStorageFilename(user, channel, file string) string {
if user == "_" && channel == "_" {
return file
}
return user + "@" + channel + "_" + file
}
// shouldCacheFile returns true if the file should be cached.
func (h *ConanHandler) shouldCacheFile(filename string) bool {
// Cache the large archive files. Since Conan 2.25 they can also be xz or
// zstd (core.upload:compression_format).
cacheFiles := []string{
"conan_sources.tgz", "conan_sources.txz", "conan_sources.tzst",
"conan_export.tgz", "conan_export.txz", "conan_export.tzst",
"conan_package.tgz", "conan_package.txz", "conan_package.tzst",
}
for _, f := range cacheFiles {
if filename == f {
return true
}
}
return false
}
// proxyCached forwards a request with metadata caching.
func (h *ConanHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
cacheKey := strings.TrimPrefix(r.URL.Path, "/")
cacheKey = strings.ReplaceAll(cacheKey, "/", "_")
if r.URL.RawQuery != "" {
cacheKey += "_" + r.URL.RawQuery
}
upstreamURL := h.upstreamURL + r.URL.Path
if r.URL.RawQuery != "" {
upstreamURL += "?" + r.URL.RawQuery
}
h.proxy.ProxyCached(w, r, upstreamURL, "conan", cacheKey, "*/*")
}
// proxyUpstream forwards a request to conan center without caching.
func (h *ConanHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path
if r.URL.RawQuery != "" {
upstreamURL += "?" + r.URL.RawQuery
}
h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
// Copy authorization header for authenticated requests
if auth := r.Header.Get("Authorization"); auth != "" {
req.Header.Set("Authorization", auth)
}
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
h.proxy.relayResponse(w, r, resp, nil)
}