Repository: peimanja/artifactory_exporter
Pull request #158: Fix timeout handling
Author: login: chewrocca
Created: 2025-04-01T20:54:22Z | Merged: 2025-04-14T20:24:25Z

Fix timeout handling in FetchMirrorLags and FetchUnavailableMirrors functions.

This PR updates the FetchMirrorLags and FetchUnavailableMirrors methods to properly handle timeout and network-related errors without panicking.
Previously, the code assumed all errors were *artifactory.APIError and performed an unsafe type assertion, which caused a panic if a different error type (e.g., *url.Error) occurred — such as when a timeout was reached.

This was discovered while scraping /metrics via Prometheus and using a reverse proxy. When Artifactory took too long to respond, the exporter panicked due to improper error handling.

```bash
panic: interface conversion: error is *url.Error, not *artifactory.APIError
time=2025-04-01T18:36:43.579Z level=ERROR msg="There was an error making API call" endpoint=https://artifactory.example.com/artifactory/api/federation/status/mirrorsLag err="Get \"https://artifactory.example.com/artifactory/api/federation/status/mirrorsLag\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)"

goroutine 103997 [running]:
github.com/peimanja/artifactory_exporter/artifactory.(*Client).FetchMirrorLags(0xc00025c1b0)
	/go/artifactory_exporter/artifactory/federation.go:38 +0x33a
github.com/peimanja/artifactory_exporter/collector.(*Exporter).exportFederationMirrorLags(0xc000235ab0, 0x94f1c1?)
	/go/artifactory_exporter/collector/federation.go:11 +0x38
github.com/peimanja/artifactory_exporter/collector.(*Exporter).scrape(0xc000235ab0, 0xc0002e87e0?)
	/go/artifactory_exporter/collector/collector.go:188 +0x2c5
github.com/peimanja/artifactory_exporter/collector.(*Exporter).Collect(0xc000235ab0, 0xc000151f60?)
	/go/artifactory_exporter/collector/collector.go:132 +0x75
github.com/prometheus/client_golang/prometheus.(*Registry).Gather.func1()
	/go/pkg/mod/github.com/prometheus/client_golang@v1.11.1/prometheus/registry.go:446 +0x102
created by github.com/prometheus/client_golang/prometheus.(*Registry).Gather in goroutine 103993
	/go/pkg/mod/github.com/prometheus/client_golang@v1.11.1/prometheus/registry.go:538 +0xb4b

time=2025-04-01T18:37:01.039Z level=INFO msg="Starting artifactory_exporter" version="(version=v1.15.1, branch=refs/tags/v1.15.1, revision=8fb37cdb73624c7d40f2d980cc2d9603cb1a5948)"
time=2025-04-01T18:37:01.039Z level=INFO msg="Build context" context="(go=go1.21.13, user=github-actions, date=2025-02-27T17:08:02Z)"
time=2025-04-01T18:37:01.039Z level=INFO msg="Listening on address" address=:9531
```

Reproduced using `artifactory_exporter v1.15.1` with `Prometheus v2.53.1` and Artifactory behind a proxy with long response times.




Diff:
diff --git a/artifactory/client.go b/artifactory/client.go
index 09b77d5..616d67c 100644
--- a/artifactory/client.go
+++ b/artifactory/client.go
@@ -1,7 +1,10 @@
 package artifactory
 
 import (
+	"context"
 	"crypto/tls"
+	"fmt"
+	"io"
 	"log/slog"
 	"net/http"
 
@@ -40,3 +43,28 @@ func NewClient(conf *config.Config) *Client {
 func (c *Client) GetAccessFederationTarget() string {
 	return c.accessFederationTarget
 }
+
+// FetchHTTPWithContext makes a GET request to the Artifactory API with a context-aware timeout.
+func (c *Client) FetchHTTPWithContext(ctx context.Context, endpoint string) (*ApiResponse, error) {
+	fullURL := fmt.Sprintf("%s/api/%s", c.URI, endpoint)
+	req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	return &ApiResponse{
+		Body:       body,
+		NodeId: resp.Header.Get("X-Artifactory-Node-Id"),
+	}, nil
+}
diff --git a/artifactory/federation.go b/artifactory/federation.go
index 097cd3e..937aed9 100644
--- a/artifactory/federation.go
+++ b/artifactory/federation.go
@@ -1,7 +1,11 @@
 package artifactory
 
 import (
+	"context"
 	"encoding/json"
+	"errors"
+	"net/url"
+	"time"
 )
 
 const federationMirrorsLagEndpoint = "federation/status/mirrorsLag"
@@ -29,21 +33,42 @@ type MirrorLags struct {
 	NodeId     string
 }
 
+type UnavailableMirror struct {
+	RepoKey       string `json:"repoKey"`
+	NodeId        string `json:"nodeId"`
+	Status        string `json:"status"`
+	LocalRepoKey  string `json:"localRepoKey"`
+	RemoteUrl     string `json:"remoteUrl"`
+	RemoteRepoKey string `json:"remoteRepoKey"`
+}
+
+type UnavailableMirrors struct {
+	UnavailableMirrors []UnavailableMirror
+	NodeId             string
+}
+
 // FetchMirrorLags makes the API call to federation/status/mirrorsLag endpoint and returns []MirrorLag
 func (c *Client) FetchMirrorLags() (MirrorLags, error) {
 	var mirrorLags MirrorLags
 	c.logger.Debug("Fetching mirror lags")
+
 	resp, err := c.FetchHTTP(federationMirrorsLagEndpoint)
 	if err != nil {
-		if err.(*APIError).status == 404 {
+		var apiErr *APIError
+		var urlErr *url.Error
+		if errors.As(err, &apiErr) && apiErr.status == 404 {
 			return mirrorLags, nil
+		} else if errors.As(err, &urlErr) {
+			c.logger.Error("URL error while fetching mirror lags: ", urlErr)
+			return mirrorLags, err
+		} else {
+			return mirrorLags, err
 		}
-		return mirrorLags, err
 	}
 	mirrorLags.NodeId = resp.NodeId
 
 	if err := json.Unmarshal(resp.Body, &mirrorLags.MirrorLags); err != nil {
-		c.logger.Error("There was an issue when try to unmarshal mirror lags respond")
+		c.logger.Error("There was an issue when trying to unmarshal mirror lags response: ", err)
 		return mirrorLags, &UnmarshalError{
 			message:  err.Error(),
 			endpoint: federationMirrorsLagEndpoint,
@@ -53,34 +78,31 @@ func (c *Client) FetchMirrorLags() (MirrorLags, error) {
 	return mirrorLags, nil
 }
 
-// UnavailableMirror represents single element of API respond from federation/status/unavailableMirrors endpoint
-type UnavailableMirror struct {
-	LocalRepoKey  string `json:"localRepoKey"`
-	RemoteUrl     string `json:"remoteUrl"`
-	RemoteRepoKey string `json:"remoteRepoKey"`
-	Status        string `json:"status"`
-}
-
-type UnavailableMirrors struct {
-	UnavailableMirrors []UnavailableMirror
-	NodeId             string
-}
-
 // FetchUnavailableMirrors makes the API call to federation/status/unavailableMirrors endpoint and returns []UnavailableMirror
 func (c *Client) FetchUnavailableMirrors() (UnavailableMirrors, error) {
 	var unavailableMirrors UnavailableMirrors
 	c.logger.Debug("Fetching unavailable mirrors")
-	resp, err := c.FetchHTTP(federationUnavailableMirrorsEndpoint)
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	resp, err := c.FetchHTTPWithContext(ctx, federationUnavailableMirrorsEndpoint)
 	if err != nil {
-		if err.(*APIError).status == 404 {
+		var apiErr *APIError
+		var urlErr *url.Error
+		if errors.As(err, &apiErr) && apiErr.status == 404 {
 			return unavailableMirrors, nil
+		} else if errors.As(err, &urlErr) {
+			c.logger.Error("URL error while fetching unavailable mirrors: ", urlErr)
+			return unavailableMirrors, err
+		} else {
+			return unavailableMirrors, err
 		}
-		return unavailableMirrors, err
 	}
 	unavailableMirrors.NodeId = resp.NodeId
 
 	if err := json.Unmarshal(resp.Body, &unavailableMirrors.UnavailableMirrors); err != nil {
-		c.logger.Error("There was an issue when try to unmarshal unavailable mirrors respond")
+		c.logger.Error("There was an issue when trying to unmarshal unavailable mirrors response: ", err)
 		return unavailableMirrors, &UnmarshalError{
 			message:  err.Error(),
 			endpoint: federationUnavailableMirrorsEndpoint,


Reviews:
user: login: KacperPerschke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T17:54:57Z
user: login: KacperPerschke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T17:56:53Z
user: login: KacperPerschke
body: I have not analysed whether types `UnavailableMirror` and `UnavailableMirrors` can be removed.

I like the nature of the change and the solutions used in it very much.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T17:59:01Z
user: login: KacperPerschke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:58:33Z
user: login: KacperPerschke
body: Commit `3a74e7a` compiles with go1.23.5 brilliantly.

I can learn from @chewrocca to create clear code that is in line with the ideology behind the go language team. I wish this project more collaborators like him.

A minor comment regarding the comment is for consideration by the author of the PR or the owner of the repository.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:06:44Z
user: login: peimanja
body: LGTM
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-14T20:23:15Z

Review comments:
diff_hunk: @@ -33,17 +33,24 @@ type MirrorLags struct {
 func (c *Client) FetchMirrorLags() (MirrorLags, error) {
 	var mirrorLags MirrorLags
 	c.logger.Debug("Fetching mirror lags")
+
 	resp, err := c.FetchHTTP(federationMirrorsLagEndpoint)
 	if err != nil {
-		if err.(*APIError).status == 404 {
path: artifactory/federation.go
user: login: KacperPerschke
body: Replacing the construction from line 38 with construction in line 41 pleases me very much. Full support from me!
created_at: 2025-04-07T17:54:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,34 +60,31 @@ func (c *Client) FetchMirrorLags() (MirrorLags, error) {
 	return mirrorLags, nil
 }
 
-// UnavailableMirror represents single element of API respond from federation/status/unavailableMirrors endpoint
-type UnavailableMirror struct {
-	LocalRepoKey  string `json:"localRepoKey"`
-	RemoteUrl     string `json:"remoteUrl"`
-	RemoteRepoKey string `json:"remoteRepoKey"`
-	Status        string `json:"status"`
-}
-
-type UnavailableMirrors struct {
-	UnavailableMirrors []UnavailableMirror
-	NodeId             string
-}
-
 // FetchUnavailableMirrors makes the API call to federation/status/unavailableMirrors endpoint and returns []UnavailableMirror
 func (c *Client) FetchUnavailableMirrors() (UnavailableMirrors, error) {
 	var unavailableMirrors UnavailableMirrors
 	c.logger.Debug("Fetching unavailable mirrors")
-	resp, err := c.FetchHTTP(federationUnavailableMirrorsEndpoint)
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // Set a timeout duration
path: artifactory/federation.go
user: login: KacperPerschke
body: Canonical use of ctx to impose a constraint on execution time. Bravo!
created_at: 2025-04-07T17:56:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,34 +78,31 @@ func (c *Client) FetchMirrorLags() (MirrorLags, error) {
 	return mirrorLags, nil
 }
 
-// UnavailableMirror represents single element of API respond from federation/status/unavailableMirrors endpoint
-type UnavailableMirror struct {
-	LocalRepoKey  string `json:"localRepoKey"`
-	RemoteUrl     string `json:"remoteUrl"`
-	RemoteRepoKey string `json:"remoteRepoKey"`
-	Status        string `json:"status"`
-}
-
-type UnavailableMirrors struct {
-	UnavailableMirrors []UnavailableMirror
-	NodeId             string
-}
-
 // FetchUnavailableMirrors makes the API call to federation/status/unavailableMirrors endpoint and returns []UnavailableMirror
 func (c *Client) FetchUnavailableMirrors() (UnavailableMirrors, error) {
 	var unavailableMirrors UnavailableMirrors
 	c.logger.Debug("Fetching unavailable mirrors")
-	resp, err := c.FetchHTTP(federationUnavailableMirrorsEndpoint)
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // Set a timeout duration
+	defer cancel()
+
+	resp, err := c.FetchHTTPWithContext(ctx, federationUnavailableMirrorsEndpoint) // Assume FetchHTTPWithContext handles context
path: artifactory/federation.go
user: login: KacperPerschke
body: @chewrocca, if you have provided `FetchHTTPWithContext` please consider removing comment in this line.
created_at: 2025-04-08T17:58:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: KacperPerschke
created_at: 2025-04-07T18:10:53Z
body: 1. It needs rebase to commit ee6ca016bdc620d61f6436599bfa9c03a66b3dac
2. I did pull it and tried to `go build` and got errors, which implies the need for a little deeper analysis. 

`go build` errors:
```
# github.com/peimanja/artifactory_exporter/artifactory
artifactory/federation.go:40:15: undefined: url
artifactory/federation.go:41:6: undefined: errors
artifactory/federation.go:43:13: undefined: errors
artifactory/federation.go:64:45: undefined: UnavailableMirrors
artifactory/federation.go:65:25: undefined: UnavailableMirrors
artifactory/federation.go:68:17: undefined: context
artifactory/federation.go:68:61: undefined: time
artifactory/federation.go:71:17: c.FetchHTTPWithContext undefined (type *Client has no field or method FetchHTTPWithContext)
artifactory/federation.go:74:15: undefined: url
artifactory/federation.go:75:6: undefined: errors
artifactory/federation.go:75:6: too many errors
```

Rather, it looks quite simple to solve. Sorry, but I do not have the time to sit down to this at the moment.

@chewrocca could you, please, rebase to master?
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-08T18:54:04Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=peimanja_artifactory_exporter&pullRequest=158) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=peimanja_artifactory_exporter&pullRequest=158&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=peimanja_artifactory_exporter&pullRequest=158&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=peimanja_artifactory_exporter&pullRequest=158&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=peimanja_artifactory_exporter&pullRequest=158&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=peimanja_artifactory_exporter&pullRequest=158&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=peimanja_artifactory_exporter&pullRequest=158)


author_association: NONE