diff --git a/cmd/kata/conn_dropped_unix.go b/cmd/kata/conn_dropped_unix.go new file mode 100644 index 00000000..fa6009c9 --- /dev/null +++ b/cmd/kata/conn_dropped_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import ( + "errors" + "syscall" +) + +func connDroppedErrno(err error) bool { + return errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) +} diff --git a/cmd/kata/conn_dropped_unix_test.go b/cmd/kata/conn_dropped_unix_test.go new file mode 100644 index 00000000..cd29dda4 --- /dev/null +++ b/cmd/kata/conn_dropped_unix_test.go @@ -0,0 +1,33 @@ +//go:build !windows + +package main + +import ( + "net" + "net/http" + "net/url" + "os" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConnectionDroppedErrnosUnix(t *testing.T) { + for _, errno := range []syscall.Errno{syscall.EPIPE, syscall.ECONNRESET} { + err := &url.Error{Op: http.MethodPost, URL: "https://daemon.example/issues", Err: &net.OpError{ + Op: "write", Net: "tcp", Err: errno, + }} + got := createRequestError(err, false) + var cliErr *cliError + require.ErrorAs(t, got, &cliErr) + assert.Equal(t, "create_outcome_unknown", cliErr.Code) + assert.NotContains(t, cliErr.Message, "daemon.example") + } + + refused := &url.Error{Op: http.MethodPost, URL: "https://daemon.example/issues", Err: &net.OpError{ + Op: "dial", Err: os.NewSyscallError("connect", syscall.ECONNREFUSED), + }} + assert.Same(t, refused, createRequestError(refused, false)) +} diff --git a/cmd/kata/conn_dropped_windows.go b/cmd/kata/conn_dropped_windows.go new file mode 100644 index 00000000..42bd2872 --- /dev/null +++ b/cmd/kata/conn_dropped_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package main + +import ( + "errors" + "syscall" +) + +func connDroppedErrno(err error) bool { + return errors.Is(err, syscall.WSAECONNRESET) || + errors.Is(err, syscall.WSAECONNABORTED) || + errors.Is(err, syscall.ERROR_BROKEN_PIPE) +} diff --git a/cmd/kata/conn_dropped_windows_test.go b/cmd/kata/conn_dropped_windows_test.go new file mode 100644 index 00000000..9f711d52 --- /dev/null +++ b/cmd/kata/conn_dropped_windows_test.go @@ -0,0 +1,36 @@ +//go:build windows + +package main + +import ( + "net" + "net/http" + "net/url" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConnectionDroppedErrnosWindows(t *testing.T) { + for _, errno := range []syscall.Errno{ + syscall.WSAECONNRESET, + syscall.WSAECONNABORTED, + syscall.ERROR_BROKEN_PIPE, + } { + err := &url.Error{Op: http.MethodPost, URL: "https://daemon.example/issues", Err: &net.OpError{ + Op: "write", Net: "tcp", Err: errno, + }} + got := createRequestError(err, false) + var cliErr *cliError + require.ErrorAs(t, got, &cliErr) + assert.Equal(t, "create_outcome_unknown", cliErr.Code) + assert.NotContains(t, cliErr.Message, "daemon.example") + } + + refused := &url.Error{Op: http.MethodPost, URL: "https://daemon.example/issues", Err: &net.OpError{ + Op: "dial", Err: syscall.Errno(10061), // WSAECONNREFUSED + }} + assert.Same(t, refused, createRequestError(refused, false)) +} diff --git a/cmd/kata/create.go b/cmd/kata/create.go index 48951470..113e7ad5 100644 --- a/cmd/kata/create.go +++ b/cmd/kata/create.go @@ -201,6 +201,10 @@ func createRequestError(err error, forceNew bool) error { reason = "request timed out" case errors.Is(err, context.Canceled): reason = "request canceled before the response arrived" + case responseBodyWasCut(err): + reason = "the response was cut off before it completed" + case connectionDroppedBeforeResponse(err): + reason = "the connection dropped before the response arrived" default: return err } @@ -222,10 +226,19 @@ func requestTimedOut(err error) bool { return true } var netErr net.Error - if !errors.As(err, &netErr) || netErr == nil { - return false - } - return netErr.Timeout() + // netErr != nil is guaranteed when errors.As returns true; the explicit + // check exists to satisfy NilAway (same idiom as internal/federation). + return errors.As(err, &netErr) && netErr != nil && netErr.Timeout() +} + +func responseBodyWasCut(err error) bool { + var readErr *responseBodyReadError + return errors.As(err, &readErr) +} + +func connectionDroppedBeforeResponse(err error) bool { + return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || + connDroppedErrno(err) } // initialLinksAsChanges builds a synthetic mutationChanges from the diff --git a/cmd/kata/create_test.go b/cmd/kata/create_test.go index 6c641d46..33c12712 100644 --- a/cmd/kata/create_test.go +++ b/cmd/kata/create_test.go @@ -64,6 +64,16 @@ func TestCreateRequestError(t *testing.T) { assert.Same(t, otherErr, createRequestError(otherErr, false)) } +func TestCreateRequestErrorConnectionDropped(t *testing.T) { + dropped := &url.Error{Op: http.MethodPost, URL: "https://daemon.example/issues", Err: io.EOF} + got := createRequestError(dropped, false) + var cliErr *cliError + require.ErrorAs(t, got, &cliErr) + assert.Equal(t, "create_outcome_unknown", cliErr.Code) + assert.Contains(t, cliErr.Message, "connection dropped") + +} + func TestCreateRequestErrorCanceled(t *testing.T) { canceledErr := &url.Error{ Op: http.MethodPost, @@ -172,6 +182,45 @@ func TestCreateTimeoutClassificationAtCommandBoundary(t *testing.T) { }) } +func TestCreateResponseBodyCutClassificationAtCommandBoundary(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/projects/resolve": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"project":{"id":7,"name":"example-project"}}`) + case "/api/v1/projects/7/issues": + _, _ = io.Copy(io.Discard, r.Body) + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking unsupported", http.StatusInternalServerError) + return + } + conn, buf, err := hj.Hijack() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + _, _ = buf.WriteString("HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: 512\r\n\r\n" + + `{"issue":{"short_id":"abc4","title":"example issue","status":"open"`) + _ = buf.Flush() + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + ctx := contextWithBaseURL(context.Background(), server.URL) + _, _, err := executeRootCapture(t, ctx, "--workspace", t.TempDir(), + "create", "example issue") + cliErr := requireCLIError(t, err, ExitInternal) + assert.Equal(t, "create_outcome_unknown", cliErr.Code) + assert.Contains(t, cliErr.Message, "cut off") + assert.Contains(t, cliErr.Message, "check whether the issue was created") + assert.NotContains(t, cliErr.Message, "timed out") +} + func TestCreate_PrintsIssueShortIDInQuietMode(t *testing.T) { env, dir := setupCLIEnv(t) out := runCLI(t, env, dir, "--quiet", "create", "first issue", "--body", "details") diff --git a/cmd/kata/delete.go b/cmd/kata/delete.go index 276e790f..d57279b7 100644 --- a/cmd/kata/delete.go +++ b/cmd/kata/delete.go @@ -244,6 +244,16 @@ func isShellSafeArg(s string) bool { return true } +// responseBodyReadError marks a response that was cut off after its headers +// arrived, when a mutating request may already have committed. +type responseBodyReadError struct{ err error } + +func (e *responseBodyReadError) Error() string { + return "read response body: " + e.err.Error() +} + +func (e *responseBodyReadError) Unwrap() error { return e.err } + // httpDoJSONWithHeader mirrors httpDoJSON but lets callers attach extra // request headers (notably X-Kata-Confirm). Defined here so delete and the // upcoming purge command don't have to extend the helpers.go signature. @@ -274,7 +284,7 @@ func httpDoJSONWithHeader(ctx context.Context, client *http.Client, defer func() { _ = resp.Body.Close() }() out, err := io.ReadAll(resp.Body) if err != nil { - return 0, nil, err + return 0, nil, &responseBodyReadError{err: err} } return resp.StatusCode, out, nil } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1d637b57..6be9a5ef 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -139,10 +139,11 @@ kata create