Add debug info while issuing request (#50)

* Add debug info while issuing request

* wip

* wip

* Rework tests
This commit is contained in:
Nikola Jokic
2026-01-30 13:03:22 +01:00
committed by GitHub
parent 145b7e382f
commit c841c96f1c
7 changed files with 423 additions and 612 deletions
+196 -195
View File
@@ -2,8 +2,10 @@ package scaleset
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
@@ -11,129 +13,14 @@ import (
"github.com/stretchr/testify/require"
)
func TestActionsError(t *testing.T) {
t.Run("contains the status code, activity ID, and error", func(t *testing.T) {
err := &ActionsError{
ActivityID: "activity-id",
StatusCode: 404,
Err: errors.New("example error description"),
}
type readErrCloser struct{}
s := err.Error()
assert.Contains(t, s, "StatusCode 404")
assert.Contains(t, s, "ActivityId \"activity-id\"")
assert.Contains(t, s, "example error description")
})
t.Run("unwraps the error", func(t *testing.T) {
err := &ActionsError{
ActivityID: "activity-id",
StatusCode: 404,
Err: &actionsExceptionError{
ExceptionName: "exception-name",
Message: "example error message",
},
}
assert.Equal(t, err.Unwrap(), err.Err)
})
t.Run("is exception is ok", func(t *testing.T) {
err := &ActionsError{
ActivityID: "activity-id",
StatusCode: 404,
Err: &actionsExceptionError{
ExceptionName: "exception-name",
Message: "example error message",
},
}
var exception *actionsExceptionError
assert.True(t, errors.As(err, &exception))
assert.True(t, err.isException("exception-name"))
})
t.Run("is exception is not ok", func(t *testing.T) {
tt := map[string]*ActionsError{
"not an exception": {
ActivityID: "activity-id",
StatusCode: 404,
Err: errors.New("example error description"),
},
"not target exception": {
ActivityID: "activity-id",
StatusCode: 404,
Err: &actionsExceptionError{
ExceptionName: "exception-name",
Message: "example error message",
},
},
}
targetException := "target-exception"
for name, err := range tt {
t.Run(name, func(t *testing.T) {
assert.False(t, err.isException(targetException))
})
}
})
t.Run("is agent exists exception", func(t *testing.T) {
err := &ActionsError{
ActivityID: "activity-id",
StatusCode: 404,
Err: &actionsExceptionError{
ExceptionName: "AgentExistsException",
Message: "example error message",
},
}
assert.True(t, err.IsAgentExists())
})
t.Run("is agent not found exception", func(t *testing.T) {
err := &ActionsError{
ActivityID: "activity-id",
StatusCode: 404,
Err: &actionsExceptionError{
ExceptionName: "AgentNotFoundException",
Message: "example error message",
},
}
assert.True(t, err.IsAgentNotFound())
})
t.Run("is job still running exception", func(t *testing.T) {
err := &ActionsError{
ActivityID: "activity-id",
StatusCode: 404,
Err: &actionsExceptionError{
ExceptionName: "JobStillRunningException",
Message: "example error message",
},
}
assert.True(t, err.IsJobStillRunning())
})
t.Run("is message queue token expired exception", func(t *testing.T) {
err := &ActionsError{
ActivityID: "activity-id",
StatusCode: 401,
Err: &messageQueueTokenExpiredError{
message: "example error message",
},
}
assert.True(t, err.IsMessageQueueTokenExpired())
})
}
func (readErrCloser) Read([]byte) (int, error) { return 0, fmt.Errorf("read failed") }
func (readErrCloser) Close() error { return nil }
func TestActionsExceptionError(t *testing.T) {
t.Run("contains the exception name and message", func(t *testing.T) {
err := &actionsExceptionError{
err := actionsExceptionError{
ExceptionName: "exception-name",
Message: "example error message",
}
@@ -144,109 +31,223 @@ func TestActionsExceptionError(t *testing.T) {
})
}
func TestGitHubAPIError(t *testing.T) {
t.Run("contains the status code, request ID, and error", func(t *testing.T) {
err := &GitHubAPIError{
StatusCode: 404,
RequestID: "request-id",
Err: errors.New("example error description"),
}
func TestNewRequestResponseError(t *testing.T) {
req := func(t *testing.T) *http.Request {
t.Helper()
u, err := url.Parse("https://example.com/org/repo")
require.NoError(t, err)
return &http.Request{Method: http.MethodGet, URL: u}
}
s := err.Error()
assert.Contains(t, s, "StatusCode 404")
assert.Contains(t, s, "RequestID \"request-id\"")
assert.Contains(t, s, "example error description")
t.Run("resp is nil", func(t *testing.T) {
base := errors.New("base")
err := newRequestResponseError(req(t), nil, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "request GET https://example.com/org/repo failed")
assert.True(t, errors.Is(err, base))
})
t.Run("unwraps the error", func(t *testing.T) {
err := &GitHubAPIError{
StatusCode: 404,
RequestID: "request-id",
Err: errors.New("example error description"),
t.Run("resp body is nil", func(t *testing.T) {
base := errors.New("base")
resp := &http.Response{
Status: "500 Internal Server Error",
StatusCode: http.StatusInternalServerError,
ContentLength: 123,
Header: make(http.Header),
Body: nil,
}
assert.Equal(t, err.Unwrap(), err.Err)
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown error")
assert.True(t, errors.Is(err, base))
})
}
func TestParseActionsErrorFromResponse(t *testing.T) {
t.Run("empty content length", func(t *testing.T) {
response := &http.Response{
t.Run("empty body returns unknown error", func(t *testing.T) {
base := errors.New("base")
resp := &http.Response{
Status: "404 Not Found",
StatusCode: http.StatusNotFound,
ContentLength: 0,
Header: http.Header{},
StatusCode: 404,
Header: make(http.Header),
}
response.Header.Add(headerActionsActivityID, "activity-id")
resp.Header.Set(headerActionsActivityID, "activity-id")
resp.Header.Set(headerGitHubRequestID, "request-id")
err := ParseActionsErrorFromResponse(response)
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Equal(t, "activity-id", err.(*ActionsError).ActivityID)
assert.Equal(t, 404, err.(*ActionsError).StatusCode)
assert.Equal(t, "unknown exception", err.(*ActionsError).Err.Error())
assert.Contains(t, err.Error(), "status=\"404 Not Found\"")
assert.Contains(t, err.Error(), "activity_id=\"activity-id\"")
assert.Contains(t, err.Error(), "github_request_id=\"request-id\"")
assert.Contains(t, err.Error(), "unknown error")
assert.True(t, errors.Is(err, base))
})
t.Run("contains text plain error", func(t *testing.T) {
errorMessage := "example error message"
response := &http.Response{
ContentLength: int64(len(errorMessage)),
StatusCode: 404,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(errorMessage)),
t.Run("read body failure includes read error", func(t *testing.T) {
base := errors.New("base")
resp := &http.Response{
Status: "400 Bad Request",
StatusCode: http.StatusBadRequest,
ContentLength: 1,
Header: make(http.Header),
Body: io.NopCloser(readErrCloser{}),
}
response.Header.Add(headerActionsActivityID, "activity-id")
response.Header.Add("Content-Type", "text/plain")
err := ParseActionsErrorFromResponse(response)
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
var actionsError *ActionsError
assert.ErrorAs(t, err, &actionsError)
assert.Equal(t, "activity-id", actionsError.ActivityID)
assert.Equal(t, 404, actionsError.StatusCode)
assert.Equal(t, errorMessage, actionsError.Err.Error())
assert.Contains(t, err.Error(), "failed to read error response body")
assert.True(t, errors.Is(err, base))
assert.Contains(t, err.Error(), "read failed")
})
t.Run("contains json error", func(t *testing.T) {
errorMessage := `{"typeName":"exception-name","message":"example error message"}`
response := &http.Response{
ContentLength: int64(len(errorMessage)),
StatusCode: 404,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(errorMessage)),
t.Run("unknown content length and empty body returns unknown error", func(t *testing.T) {
base := errors.New("base")
resp := &http.Response{
Status: "400 Bad Request",
StatusCode: http.StatusBadRequest,
ContentLength: -1,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
response.Header.Add(headerActionsActivityID, "activity-id")
response.Header.Add("Content-Type", "application/json")
err := ParseActionsErrorFromResponse(response)
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
var actionsError *ActionsError
assert.ErrorAs(t, err, &actionsError)
assert.Equal(t, "activity-id", actionsError.ActivityID)
assert.Equal(t, 404, actionsError.StatusCode)
inner, ok := actionsError.Err.(*actionsExceptionError)
require.True(t, ok)
assert.Equal(t, "exception-name", inner.ExceptionName)
assert.Equal(t, "example error message", inner.Message)
assert.Contains(t, err.Error(), "unknown error")
assert.True(t, errors.Is(err, base))
})
t.Run("wrapped exception error", func(t *testing.T) {
errorMessage := `{"typeName":"exception-name","message":"example error message"}`
response := &http.Response{
ContentLength: int64(len(errorMessage)),
StatusCode: 404,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(errorMessage)),
t.Run("text/plain body is included", func(t *testing.T) {
base := errors.New("base")
body := "example plain text error"
resp := &http.Response{
Status: "400 Bad Request",
StatusCode: http.StatusBadRequest,
ContentLength: int64(len(body)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
response.Header.Add(headerActionsActivityID, "activity-id")
response.Header.Add("Content-Type", "application/json")
resp.Header.Set("Content-Type", "text/plain")
resp.Header.Set(headerActionsActivityID, "activity-id")
err := ParseActionsErrorFromResponse(response)
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), body)
assert.True(t, errors.Is(err, base))
})
var actionsExceptionError *actionsExceptionError
assert.ErrorAs(t, err, &actionsExceptionError)
t.Run("scalesetError in error chain uses raw body (no JSON parsing)", func(t *testing.T) {
wrapped := fmt.Errorf("wrapped: %w", RunnerNotFoundError)
body := `{"typeName":"AgentExistsException","message":"should not be parsed"}`
resp := &http.Response{
Status: "404 Not Found",
StatusCode: http.StatusNotFound,
ContentLength: int64(len(body)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
resp.Header.Set("Content-Type", "application/json")
assert.Equal(t, "exception-name", actionsExceptionError.ExceptionName)
assert.Equal(t, "example error message", actionsExceptionError.Message)
err := newRequestResponseError(req(t), resp, wrapped)
require.Error(t, err)
assert.True(t, errors.Is(err, RunnerNotFoundError))
assert.Contains(t, err.Error(), body)
})
t.Run("known actions exception maps to sentinel error", func(t *testing.T) {
base := errors.New("base")
jsonBody := `{"typeName":"AgentExistsException","message":"runner already exists"}`
resp := &http.Response{
Status: "409 Conflict",
StatusCode: http.StatusConflict,
ContentLength: int64(len(jsonBody)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.True(t, errors.Is(err, RunnerExistsError))
assert.False(t, errors.Is(err, base), "base error should not be wrapped for mapped exceptions")
assert.Contains(t, err.Error(), "runner already exists")
})
t.Run("agent not found exception maps to sentinel error", func(t *testing.T) {
base := errors.New("base")
jsonBody := `{"typeName":"AgentNotFoundException","message":"missing"}`
resp := &http.Response{
Status: "404 Not Found",
StatusCode: http.StatusNotFound,
ContentLength: int64(len(jsonBody)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.True(t, errors.Is(err, RunnerNotFoundError))
assert.False(t, errors.Is(err, base))
assert.Contains(t, err.Error(), "missing")
})
t.Run("job still running exception maps to sentinel error", func(t *testing.T) {
base := errors.New("base")
jsonBody := `{"typeName":"JobStillRunningException","message":"still running"}`
resp := &http.Response{
Status: "409 Conflict",
StatusCode: http.StatusConflict,
ContentLength: int64(len(jsonBody)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.True(t, errors.Is(err, JobStillRunningError))
assert.False(t, errors.Is(err, base))
assert.Contains(t, err.Error(), "still running")
})
t.Run("invalid json returns unmarshal error and includes body", func(t *testing.T) {
base := errors.New("base")
bad := "not-json"
resp := &http.Response{
Status: "400 Bad Request",
StatusCode: http.StatusBadRequest,
ContentLength: int64(len(bad)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(bad)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to unmarshal error response body")
assert.Contains(t, err.Error(), "not-json")
assert.False(t, errors.Is(err, base), "base error is not wrapped on JSON unmarshal failures")
})
t.Run("unknown json error wraps exception", func(t *testing.T) {
base := errors.New("base")
jsonBody := `{"typeName":"SomeException","message":"example error message"}`
resp := &http.Response{
Status: "500 Internal Server Error",
StatusCode: http.StatusInternalServerError,
ContentLength: int64(len(jsonBody)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.True(t, errors.Is(err, base))
var ex actionsExceptionError
assert.True(t, errors.As(err, &ex))
assert.Equal(t, "SomeException", ex.ExceptionName)
assert.Equal(t, "example error message", ex.Message)
})
}