Add enhanced function for error message handling in http request for configuration fetching (#5712)

Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
This commit is contained in:
LUKIEYF
2026-02-25 20:40:14 +08:00
committed by GitHub
parent 59757e1443
commit b806e98cba
6 changed files with 553 additions and 7 deletions

View File

@@ -15,7 +15,9 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -27,9 +29,14 @@ import (
"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata"
"go.woodpecker-ci.org/woodpecker/v3/server"
forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks"
forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types"
"go.woodpecker-ci.org/woodpecker/v3/server/model"
"go.woodpecker-ci.org/woodpecker/v3/server/pubsub"
queue_mocks "go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks"
config_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks"
manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks"
registry_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks"
secret_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks"
store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks"
"go.woodpecker-ci.org/woodpecker/v3/server/store/types"
)
@@ -271,3 +278,196 @@ func TestCancelPipeline(t *testing.T) {
assert.Equal(t, http.StatusNoContent, c.Writer.Status())
})
}
func TestCreatePipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
// 1. normal: config fetch succeeds (no error, returns config) -> success
t.Run("normal workflow - config can be read", func(t *testing.T) {
mockStore := store_mocks.NewMockStore(t)
mockConfigService := config_mocks.NewMockService(t)
mockSecretService := secret_mocks.NewMockService(t)
mockRegistryService := registry_mocks.NewMockService(t)
fakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: "test/repo"}
fakeUser := &model.User{ID: 1, Login: "testuser", Email: "test@example.com", Avatar: "avatar.png", Hash: "hash123"}
fakeCommit := &model.Commit{SHA: "abc123", ForgeURL: "https://example.com/commit/abc123"}
mockForge := forge_mocks.NewMockForge(t)
mockForge.On("Name").Return("mock").Maybe()
mockForge.On("URL").Return("https://example.com").Maybe()
mockForge.On("BranchHead", mock.Anything, fakeUser, fakeRepo, "main").Return(fakeCommit, nil)
mockForge.On("Netrc", fakeUser, fakeRepo).Return(&model.Netrc{
Machine: "example.com",
Login: "testuser",
Password: "testpass",
}, nil).Maybe()
mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe()
mockSecretService.On("SecretListPipeline", fakeRepo, mock.Anything).Return([]*model.Secret{}, nil).Maybe()
mockRegistryService.On("RegistryListPipeline", fakeRepo, mock.Anything).Return([]*model.Registry{}, nil).Maybe()
mockManager := manager_mocks.NewMockManager(t)
mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil)
mockManager.On("ConfigServiceFromRepo", fakeRepo).Return(mockConfigService)
mockManager.On("SecretServiceFromRepo", fakeRepo).Return(mockSecretService).Maybe()
mockManager.On("RegistryServiceFromRepo", fakeRepo).Return(mockRegistryService).Maybe()
mockManager.On("EnvironmentService").Return(nil).Maybe()
server.Config.Services.Manager = mockManager
server.Config.Services.Pubsub = pubsub.New()
mockQueue := queue_mocks.NewMockQueue(t)
mockQueue.On("Push", mock.Anything, mock.Anything).Return(nil).Maybe()
mockQueue.On("PushAtOnce", mock.Anything, mock.Anything).Return(nil).Maybe()
server.Config.Services.Queue = mockQueue
// mimic the valid config data
configData := []*forge_types.FileMeta{
{Name: ".woodpecker.yml", Data: []byte("when:\n event: manual\nsteps:\n test:\n image: alpine:latest\n commands:\n - echo test")},
}
mockConfigService.On("Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(configData, nil)
mockStore.On("GetUser", int64(1)).Return(fakeUser, nil)
mockStore.On("CreatePipeline", mock.Anything).Return(nil)
mockStore.On("GetPipelineLastBefore", fakeRepo, "main", mock.Anything).Return(nil, nil).Maybe()
mockStore.On("ConfigPersist", mock.Anything).Return(&model.Config{ID: 1}, nil).Maybe()
mockStore.On("ConfigFindIdentical", mock.Anything, mock.Anything).Return(nil, nil).Maybe()
mockStore.On("PipelineConfigCreate", mock.Anything).Return(nil).Maybe()
mockStore.On("WorkflowsCreate", mock.Anything).Return(nil).Maybe()
mockStore.On("UpdatePipeline", mock.Anything).Return(nil).Maybe()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Set("repo", fakeRepo)
c.Set("user", fakeUser)
c.Request, _ = http.NewRequest(http.MethodPost, "", io.NopCloser(bytes.NewBufferString(`{"branch": "main"}`)))
c.Request.Header.Set("Content-Type", "application/json")
CreatePipeline(c)
// verify the config service was called successfully (no error, returns config)
mockConfigService.AssertCalled(t, "Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false)
mockForge.AssertCalled(t, "BranchHead", mock.Anything, fakeUser, fakeRepo, "main")
mockStore.AssertCalled(t, "GetUser", int64(1))
mockStore.AssertCalled(t, "CreatePipeline", mock.Anything)
})
// 2. abnormal with oldconfig: config fetch fails but returns config data (error + non-nil config) -> continues with fallback
t.Run("abnormal workflow - cannot read config but has oldconfig", func(t *testing.T) {
mockStore := store_mocks.NewMockStore(t)
mockConfigService := config_mocks.NewMockService(t)
mockSecretService := secret_mocks.NewMockService(t)
mockRegistryService := registry_mocks.NewMockService(t)
fakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: "test/repo"}
fakeUser := &model.User{ID: 1, Login: "testuser", Email: "test@example.com", Avatar: "avatar.png", Hash: "hash123"}
fakeCommit := &model.Commit{SHA: "abc123", ForgeURL: "https://example.com/commit/abc123"}
mockForge := forge_mocks.NewMockForge(t)
mockForge.On("Name").Return("mock").Maybe()
mockForge.On("URL").Return("https://example.com").Maybe()
mockForge.On("BranchHead", mock.Anything, fakeUser, fakeRepo, "main").Return(fakeCommit, nil)
// mock the netrc for parse config
mockForge.On("Netrc", fakeUser, fakeRepo).Return(&model.Netrc{
Machine: "example.com",
Login: "testuser",
Password: "testpass",
}, nil).Maybe()
mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe()
mockSecretService.On("SecretListPipeline", fakeRepo, mock.Anything).Return([]*model.Secret{}, nil).Maybe()
mockRegistryService.On("RegistryListPipeline", fakeRepo, mock.Anything).Return([]*model.Registry{}, nil).Maybe()
mockManager := manager_mocks.NewMockManager(t)
mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil)
mockManager.On("ConfigServiceFromRepo", fakeRepo).Return(mockConfigService)
mockManager.On("SecretServiceFromRepo", fakeRepo).Return(mockSecretService).Maybe()
mockManager.On("RegistryServiceFromRepo", fakeRepo).Return(mockRegistryService).Maybe()
mockManager.On("EnvironmentService").Return(nil).Maybe()
server.Config.Services.Manager = mockManager
server.Config.Services.Pubsub = pubsub.New()
mockQueue := queue_mocks.NewMockQueue(t)
mockQueue.On("Push", mock.Anything, mock.Anything).Return(nil).Maybe()
mockQueue.On("PushAtOnce", mock.Anything, mock.Anything).Return(nil).Maybe()
server.Config.Services.Queue = mockQueue
// mimic the old config data
oldConfigData := []*forge_types.FileMeta{
{Name: ".woodpecker.yml", Data: []byte("when:\n event: manual\nsteps:\n test:\n image: alpine:latest\n commands:\n - echo test")},
}
mockConfigService.On("Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(oldConfigData, http.ErrHandlerTimeout)
mockStore.On("GetUser", int64(1)).Return(fakeUser, nil)
mockStore.On("CreatePipeline", mock.Anything).Return(nil)
mockStore.On("GetPipelineLastBefore", fakeRepo, "main", mock.Anything).Return(nil, nil).Maybe()
mockStore.On("ConfigPersist", mock.Anything).Return(&model.Config{ID: 1}, nil).Maybe()
mockStore.On("ConfigFindIdentical", mock.Anything, mock.Anything).Return(nil, nil).Maybe()
mockStore.On("PipelineConfigCreate", mock.Anything).Return(nil).Maybe()
mockStore.On("WorkflowsCreate", mock.Anything).Return(nil).Maybe()
mockStore.On("UpdatePipeline", mock.Anything).Return(nil).Maybe()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Set("repo", fakeRepo)
c.Set("user", fakeUser)
c.Request, _ = http.NewRequest(http.MethodPost, "", io.NopCloser(bytes.NewBufferString(`{"branch": "main"}`)))
c.Request.Header.Set("Content-Type", "application/json")
CreatePipeline(c)
// verify the config service returned error + old config (fallback scenario)
mockConfigService.AssertCalled(t, "Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false)
mockStore.AssertCalled(t, "GetUser", int64(1))
mockStore.AssertCalled(t, "CreatePipeline", mock.Anything)
})
// 3. abnormal without oldconfig: config fetch fails without config data (error + nil config) -> fails immediately
t.Run("abnormal workflow - cannot read config and no oldconfig", func(t *testing.T) {
mockStore := store_mocks.NewMockStore(t)
mockConfigService := config_mocks.NewMockService(t)
fakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: "test/repo"}
fakeUser := &model.User{ID: 1, Login: "testuser", Email: "test@example.com", Avatar: "avatar.png", Hash: "hash123"}
fakeCommit := &model.Commit{SHA: "abc123", ForgeURL: "https://example.com/commit/abc123"}
mockForge := forge_mocks.NewMockForge(t)
mockForge.On("BranchHead", mock.Anything, fakeUser, fakeRepo, "main").Return(fakeCommit, nil)
mockForge.On("Netrc", fakeUser, fakeRepo).Return(nil, nil).Maybe()
mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe()
mockManager := manager_mocks.NewMockManager(t)
mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil)
mockManager.On("ConfigServiceFromRepo", fakeRepo).Return(mockConfigService)
server.Config.Services.Manager = mockManager
server.Config.Services.Pubsub = pubsub.New()
// return nil config with error
mockConfigService.On("Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(nil, http.ErrHandlerTimeout)
mockStore.On("GetUser", int64(1)).Return(fakeUser, nil)
mockStore.On("CreatePipeline", mock.Anything).Return(nil)
mockStore.On("UpdatePipeline", mock.Anything).Return(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Set("repo", fakeRepo)
c.Set("user", fakeUser)
c.Request, _ = http.NewRequest(http.MethodPost, "", io.NopCloser(bytes.NewBufferString(`{"branch": "main"}`)))
c.Request.Header.Set("Content-Type", "application/json")
CreatePipeline(c)
// verify the config service returned error without any config data
mockConfigService.AssertCalled(t, "Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false)
mockStore.AssertCalled(t, "GetUser", int64(1))
mockStore.AssertCalled(t, "CreatePipeline", mock.Anything)
mockStore.AssertCalled(t, "UpdatePipeline", mock.Anything)
})
}

View File

@@ -79,15 +79,20 @@ func Create(ctx context.Context, _store store.Store, repo *model.Repo, pipeline
// fetch the pipeline file from the forge
configService := server.Config.Services.Manager.ConfigServiceFromRepo(repo)
forgeYamlConfigs, configFetchErr := configService.Fetch(ctx, _forge, repoUser, repo, pipeline, nil, false)
if errors.Is(configFetchErr, &forge_types.ErrConfigNotFound{}) {
switch {
case errors.Is(configFetchErr, &forge_types.ErrConfigNotFound{}):
log.Debug().Str("repo", repo.FullName).Err(configFetchErr).Msgf("cannot find config '%s' in '%s' with user: '%s'", repo.Config, pipeline.Ref, repoUser.Login)
if err := _store.DeletePipeline(pipeline); err != nil {
log.Error().Str("repo", repo.FullName).Err(err).Msg("failed to delete pipeline without config")
}
return nil, ErrFiltered
} else if configFetchErr != nil {
log.Error().Str("repo", repo.FullName).Err(configFetchErr).Msgf("error while fetching config '%s' in '%s' with user: '%s'", repo.Config, pipeline.Ref, repoUser.Login)
case configFetchErr != nil && forgeYamlConfigs != nil:
// unexpected status code from config endpoint - using previous config as fallback
log.Warn().Str("repo", repo.FullName).Err(configFetchErr).Msgf("error while fetching config '%s' in '%s' with user: '%s', will fallback to old config", repo.Config, pipeline.Ref, repoUser.Login)
case configFetchErr != nil:
// error while fetching config - not using the old config
log.Error().Str("repo", repo.FullName).Err(configFetchErr).Msgf("error while fetching config '%s' in '%s' with user: '%s', and did not get any config", repo.Config, pipeline.Ref, repoUser.Login)
return nil, updatePipelineWithErr(ctx, _forge, _store, pipeline, repo, repoUser, fmt.Errorf("could not load config from forge: %w", configFetchErr))
}

View File

@@ -19,6 +19,8 @@ import (
"fmt"
net_http "net/http"
"github.com/rs/zerolog/log"
"go.woodpecker-ci.org/woodpecker/v3/server/forge"
"go.woodpecker-ci.org/woodpecker/v3/server/forge/types"
"go.woodpecker-ci.org/woodpecker/v3/server/model"
@@ -71,14 +73,24 @@ func (h *http) Fetch(ctx context.Context, forge forge.Forge, user *model.User, r
}
status, err := h.client.Send(ctx, net_http.MethodPost, h.endpoint, body, response)
if err != nil && status != 204 {
return nil, fmt.Errorf("failed to fetch config via http (%d) %w", status, err)
if err != nil && status != net_http.StatusNoContent {
return nil, fmt.Errorf("failed to fetch config via http (status: %d): %w", status, err)
}
if status != net_http.StatusOK {
// handle 204 - no new config available, return old config without error
if status == net_http.StatusNoContent {
log.Debug().
Str("endpoint", h.endpoint).
Str("repo", repo.FullName).
Msg("config endpoint returned 204 No Content, using fallback config")
return oldConfigData, nil
}
// unexpected non-success status code
if status != net_http.StatusOK {
return oldConfigData, fmt.Errorf("unexpected status code %d from config endpoint (expected 200 or 204)", status)
}
fileMetaList := make([]*types.FileMeta, len(response.Configs))
for i, config := range response.Configs {
fileMetaList[i] = &types.FileMeta{Name: config.Name, Data: []byte(config.Data)}

View File

@@ -136,7 +136,7 @@ func (e *Client) Send(ctx context.Context, method, path string, in, out any) (in
// Create new request for each attempt
req, err := http.NewRequestWithContext(ctx, method, uri.String(), body)
if err != nil {
return 0, err
return 0, httputil.EnhanceHTTPError(err, method, path)
}
if in != nil {
req.Header.Set("Content-Type", "application/json")

View File

@@ -0,0 +1,162 @@
// Copyright 2025 Woodpecker 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 httputil
import (
"context"
"crypto/x509"
"errors"
"fmt"
"net"
"net/url"
"os"
"strings"
"syscall"
)
// EnhanceHTTPError adds detailed context to HTTP errors to help with debugging.
func EnhanceHTTPError(err error, method, endpoint string) error {
if err == nil {
return nil
}
// parse url to get host information
parsedURL, parseErr := url.Parse(endpoint)
var host string
if parseErr == nil {
host = parsedURL.Host
} else {
host = endpoint
}
// base error message
baseMsg := fmt.Sprintf("%s %q", method, endpoint)
// check for context errors
// timeout
if errors.Is(err, context.DeadlineExceeded) {
if strings.Contains(err.Error(), "Client.Timeout") {
return fmt.Errorf("connection timeout: %s: %w (the remote server at %s did not respond within the configured timeout)", baseMsg, err, host)
}
return fmt.Errorf("request timeout: %s: %w (operation took too long time)", baseMsg, err)
}
// cancellation
if errors.Is(err, context.Canceled) {
return fmt.Errorf("request canceled: %s: %w (the operation was canceled before completion)", baseMsg, err)
}
// check for net package errors
// dns error handling
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound {
return fmt.Errorf("DNS resolution failed: %s: %w (hostname %s does not exist or cannot be resolved)", baseMsg, err, host)
}
if dnsErr.IsTimeout {
return fmt.Errorf("DNS timeout: %s: %w (DNS server did not respond in time)", baseMsg, err)
}
return fmt.Errorf("DNS error: %s: %w", baseMsg, err)
}
// op error handling
var opErr *net.OpError
if errors.As(err, &opErr) {
// connection refused
if errors.Is(opErr.Err, syscall.ECONNREFUSED) {
return fmt.Errorf("connection refused: %s: %w (server at %s is not accepting connections - is it running?)", baseMsg, err, host)
}
// connection reset
if errors.Is(opErr.Err, syscall.ECONNRESET) {
return fmt.Errorf("connection reset: %s: %w (server at %s closed the connection unexpectedly)", baseMsg, err, host)
}
// network unreachable
if errors.Is(opErr.Err, syscall.ENETUNREACH) {
return fmt.Errorf("network unreachable: %s: %w (cannot reach %s - check network connectivity)", baseMsg, err, host)
}
// host unreachable
if errors.Is(opErr.Err, syscall.EHOSTUNREACH) {
return fmt.Errorf("host unreachable: %s: %w (cannot reach %s - host may be down or firewall blocking)", baseMsg, err, host)
}
// timeout during operation
if opErr.Timeout() {
return fmt.Errorf("network timeout during %s: %s: %w (operation at %s took too long)", opErr.Op, baseMsg, err, host)
}
return fmt.Errorf("network error during %s: %s: %w", opErr.Op, baseMsg, err)
}
// check for url parsing errors
var urlErr *url.Error
if errors.As(err, &urlErr) {
return fmt.Errorf("URL error: %s: %w (check if the endpoint URL is correctly formatted)", baseMsg, err)
}
// check for TLS/certificate errors
var certErr *x509.CertificateInvalidError
if errors.As(err, &certErr) {
return fmt.Errorf("TLS certificate invalid: %s: %w (certificate validation failed for %s)", baseMsg, err, host)
}
var unknownAuthErr *x509.UnknownAuthorityError
if errors.As(err, &unknownAuthErr) {
return fmt.Errorf("TLS certificate verification failed: %s: %w (certificate signed by unknown authority for %s)", baseMsg, err, host)
}
var hostErr *x509.HostnameError
if errors.As(err, &hostErr) {
return fmt.Errorf("TLS hostname mismatch: %s: %w (certificate is not valid for %s)", baseMsg, err, host)
}
// check for os errors
if errors.Is(err, os.ErrInvalid) {
return fmt.Errorf("invalid argument: %s: %w", baseMsg, err)
}
if errors.Is(err, os.ErrPermission) {
return fmt.Errorf("permission denied: %s: %w", baseMsg, err)
}
if errors.Is(err, os.ErrExist) {
return fmt.Errorf("file already exists: %s: %w", baseMsg, err)
}
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("file does not exist: %s: %w", baseMsg, err)
}
if errors.Is(err, os.ErrClosed) {
return fmt.Errorf("file already closed: %s: %w", baseMsg, err)
}
if errors.Is(err, os.ErrNoDeadline) {
return fmt.Errorf("file type does not support deadline: %s: %w", baseMsg, err)
}
if errors.Is(err, os.ErrDeadlineExceeded) {
return fmt.Errorf("i/o timeout: %s: %w", baseMsg, err)
}
// check for EOF specifically
if err.Error() == "EOF" || strings.Contains(err.Error(), "EOF") {
return fmt.Errorf("unexpected connection closure: %s: %w (server at %s closed connection prematurely - possible causes: server crash, request too large, incompatible protocol, or server-side timeout)", baseMsg, err, host)
}
// check for "connection reset by peer"
if strings.Contains(err.Error(), "connection reset by peer") {
return fmt.Errorf("connection reset by peer: %s: %w (server at %s forcibly closed the connection)", baseMsg, err, host)
}
// generic error
return fmt.Errorf("HTTP request failed: %s: %w", baseMsg, err)
}

View File

@@ -0,0 +1,167 @@
// Copyright 2024 Woodpecker 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 httputil
import (
"context"
"crypto/x509"
"errors"
"io"
"net"
"syscall"
"testing"
)
// TestEnhanceHTTPError tests the enhanceHTTPError function with various error types.
func TestEnhanceHTTPError(t *testing.T) {
tests := []struct {
name string
err error
method string
endpoint string
want string
}{
{
name: "nil error",
err: nil,
method: "POST",
endpoint: "https://example.com",
want: "",
},
{
name: "context deadline exceeded",
err: context.DeadlineExceeded,
method: "POST",
endpoint: "https://example.com/api",
want: "request timeout",
},
{
name: "context canceled",
err: context.Canceled,
method: "GET",
endpoint: "https://example.com/api",
want: "request canceled",
},
{
name: "DNS not found error",
err: &net.DNSError{
Err: "no such host",
IsNotFound: true,
IsTimeout: false,
IsTemporary: false,
},
method: "POST",
endpoint: "https://nonexistent.example.com",
want: "DNS resolution failed",
},
{
name: "DNS timeout error",
err: &net.DNSError{
Err: "timeout",
IsTimeout: true,
IsTemporary: true,
},
method: "POST",
endpoint: "https://example.com",
want: "DNS timeout",
},
{
name: "unknown authority certificate error",
err: x509.UnknownAuthorityError{},
method: "POST",
endpoint: "https://self-signed.example.com",
want: "TLS certificate verification failed",
},
{
name: "connection refused",
err: &net.OpError{
Op: "dial",
Err: syscall.ECONNREFUSED,
},
method: "POST",
endpoint: "https://localhost:9999",
want: "connection refused",
},
{
name: "connection reset",
err: &net.OpError{
Op: "read",
Err: syscall.ECONNRESET,
},
method: "POST",
endpoint: "https://example.com",
want: "connection reset",
},
{
name: "EOF error",
err: io.EOF,
method: "POST",
endpoint: "https://example.com/api",
want: "unexpected connection closure",
},
{
name: "generic error",
err: errors.New("some random error"),
method: "POST",
endpoint: "https://example.com",
want: "HTTP request failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := EnhanceHTTPError(tt.err, tt.method, tt.endpoint)
if tt.want == "" {
if got != nil {
t.Errorf("enhanceHTTPError() = %v, want nil", got)
}
return
}
if got == nil {
t.Errorf("enhanceHTTPError() = nil, want error containing %q", tt.want)
return
}
if got.Error() == "" {
t.Errorf("enhanceHTTPError() returned empty error message")
return
}
// check empty error message
errMsg := got.Error()
if len(errMsg) == 0 {
t.Errorf("enhanceHTTPError() returned empty error string")
return
}
// view the enhance error message
t.Logf("enhanced error: %v", errMsg)
})
}
}
func TestEnhanceHTTPErrorPreservesOriginal(t *testing.T) {
originalErr := io.EOF
endpoint := "https://example.com/api"
enhanced := EnhanceHTTPError(originalErr, "POST", endpoint)
// the io.EOF error should be wrapped inside the enhanced error
if !errors.Is(enhanced, originalErr) {
t.Errorf("enhanced error should wrap original error, but errors.Is returned false")
}
}