diff --git a/api/client.go b/api/client.go index 72a0130..d3386c9 100644 --- a/api/client.go +++ b/api/client.go @@ -24,6 +24,8 @@ import ( "path" "strings" "time" + + "github.com/prometheus/client_golang/internal/errcapture" ) // DefaultRoundTripper is used if no RoundTripper is set in Config. @@ -118,39 +120,17 @@ func (c *httpClient) URL(ep string, args map[string]string) *url.URL { return &u } -func (c *httpClient) Do(ctx context.Context, req *http.Request) (*http.Response, []byte, error) { +func (c *httpClient) Do(ctx context.Context, req *http.Request) (_ *http.Response, _ []byte, err error) { if ctx != nil { req = req.WithContext(ctx) } resp, err := c.client.Do(req) - defer func() { - if resp != nil { - resp.Body.Close() - } - }() - if err != nil { return nil, nil, err } + defer errcapture.ExhaustClose(&err, resp.Body, "close response body") - var body []byte - done := make(chan struct{}) - go func() { - var buf bytes.Buffer - _, err = buf.ReadFrom(resp.Body) - body = buf.Bytes() - close(done) - }() - - select { - case <-ctx.Done(): - <-done - err = resp.Body.Close() - if err == nil { - err = ctx.Err() - } - case <-done: - } - - return resp, body, err + var buf bytes.Buffer + _, err = buf.ReadFrom(resp.Body) + return resp, buf.Bytes(), err } diff --git a/internal/errcapture/errcapture.go b/internal/errcapture/errcapture.go new file mode 100644 index 0000000..d098cf3 --- /dev/null +++ b/internal/errcapture/errcapture.go @@ -0,0 +1,61 @@ +// Copyright 2014 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package errcapture + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "os" + + "github.com/prometheus/client_golang/prometheus" +) + +type doFunc func() error + +// Do runs function and on error return error by argument including the given error (usually +// from caller function). +func Do(err *error, doer doFunc, format string, a ...interface{}) { + derr := doer() + if err == nil || derr == nil { + return + } + + // For os closers, it's a common case to double close. + // From reliability purpose this is not a problem it may only indicate surprising execution path. + if errors.Is(derr, os.ErrClosed) { + return + } + + errs := prometheus.MultiError{} + errs.Append(*err) + errs.Append(fmt.Errorf(format+": %w", append(a, derr)...)) + *err = errs +} + +// ExhaustClose closes the io.ReadCloser with error capture but exhausts the reader before. +func ExhaustClose(err *error, r io.ReadCloser, format string, a ...interface{}) { + _, copyErr := io.Copy(ioutil.Discard, r) + + Do(err, r.Close, format, a...) + if copyErr == nil { + return + } + + errs := prometheus.MultiError{} + errs.Append(copyErr) + errs.Append(*err) + *err = errs +} diff --git a/internal/errcapture/errcapture_test.go b/internal/errcapture/errcapture_test.go new file mode 100644 index 0000000..d7d93a8 --- /dev/null +++ b/internal/errcapture/errcapture_test.go @@ -0,0 +1,82 @@ +// Copyright 2014 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package errcapture + +import ( + "errors" + "io" + "testing" +) + +type testCloser struct { + err error +} + +func (c testCloser) Close() error { + return c.err +} + +func TestDo(t *testing.T) { + for _, tcase := range []struct { + err error + closer io.Closer + + expectedErrStr string + }{ + { + err: nil, + closer: testCloser{err: nil}, + expectedErrStr: "", + }, + { + err: errors.New("test"), + closer: testCloser{err: nil}, + expectedErrStr: "test", + }, + { + err: nil, + closer: testCloser{err: errors.New("test")}, + expectedErrStr: "1 error(s) occurred:\n* close: test", + }, + { + err: errors.New("test"), + closer: testCloser{err: errors.New("test")}, + expectedErrStr: "2 error(s) occurred:\n* test\n* close: test", + }, + } { + if ok := t.Run("", func(t *testing.T) { + ret := tcase.err + Do(&ret, tcase.closer.Close, "close") + + if tcase.expectedErrStr == "" { + if ret != nil { + t.Error("Expected error to be nil") + t.Fail() + } + } else { + if ret == nil { + t.Error("Expected error to be not nil") + t.Fail() + } + + if tcase.expectedErrStr != ret.Error() { + t.Errorf("%s != %s", tcase.expectedErrStr, ret.Error()) + t.Fail() + } + } + }); !ok { + return + } + } +} diff --git a/prometheus/push/push.go b/prometheus/push/push.go index 06dee37..08059e7 100644 --- a/prometheus/push/push.go +++ b/prometheus/push/push.go @@ -48,6 +48,7 @@ import ( "github.com/prometheus/common/expfmt" "github.com/prometheus/common/model" + "github.com/prometheus/client_golang/internal/errcapture" "github.com/prometheus/client_golang/prometheus" ) @@ -228,7 +229,7 @@ func (p *Pusher) Format(format expfmt.Format) *Pusher { // // Delete returns the first error encountered by any method call (including this // one) in the lifetime of the Pusher. -func (p *Pusher) Delete() error { +func (p *Pusher) Delete() (err error) { if p.error != nil { return p.error } @@ -243,7 +244,8 @@ func (p *Pusher) Delete() error { if err != nil { return err } - defer resp.Body.Close() + defer errcapture.ExhaustClose(&err, resp.Body, "close response body") + if resp.StatusCode != http.StatusAccepted { body, _ := io.ReadAll(resp.Body) // Ignore any further error as this is for an error message only. return fmt.Errorf("unexpected status code %d while deleting %s: %s", resp.StatusCode, p.fullURL(), body) @@ -294,7 +296,8 @@ func (p *Pusher) push(ctx context.Context, method string) error { if err != nil { return err } - defer resp.Body.Close() + defer errcapture.ExhaustClose(&err, resp.Body, "close response body") + // Depending on version and configuration of the PGW, StatusOK or StatusAccepted may be returned. if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { body, _ := io.ReadAll(resp.Body) // Ignore any further error as this is for an error message only. diff --git a/prometheus/testutil/testutil.go b/prometheus/testutil/testutil.go index 91b83b5..7c1aa48 100644 --- a/prometheus/testutil/testutil.go +++ b/prometheus/testutil/testutil.go @@ -48,6 +48,7 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" + "github.com/prometheus/client_golang/internal/errcapture" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/internal" ) @@ -158,12 +159,12 @@ func GatherAndCount(g prometheus.Gatherer, metricNames ...string) (int, error) { // ScrapeAndCompare calls a remote exporter's endpoint which is expected to return some metrics in // plain text format. Then it compares it with the results that the `expected` would return. // If the `metricNames` is not empty it would filter the comparison only to the given metric names. -func ScrapeAndCompare(url string, expected io.Reader, metricNames ...string) error { +func ScrapeAndCompare(url string, expected io.Reader, metricNames ...string) (err error) { resp, err := http.Get(url) if err != nil { return fmt.Errorf("scraping metrics failed: %w", err) } - defer resp.Body.Close() + defer errcapture.ExhaustClose(&err, resp.Body, "close response body") if resp.StatusCode != http.StatusOK { return fmt.Errorf("the scraping target returned a status code other than 200: %d",