mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2026-03-11 21:55:01 +01:00
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:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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")
|
||||
|
||||
162
shared/httputil/http_error.go
Normal file
162
shared/httputil/http_error.go
Normal 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)
|
||||
}
|
||||
167
shared/httputil/http_error_test.go
Normal file
167
shared/httputil/http_error_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user