From 6bfcfdceda4b58a51f90d32f55d525b22cf0094c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 22:42:39 +0000 Subject: [PATCH 1/4] chore: Update dependency cspell to v8.15.2 (#572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [cspell](https://cspell.org/) ([source](https://redirect.github.com/streetsidesoftware/cspell/tree/HEAD/packages/cspell)) | [`8.15.1` -> `8.15.2`](https://renovatebot.com/diffs/npm/cspell/8.15.1/8.15.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/cspell/8.15.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/cspell/8.15.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/cspell/8.15.1/8.15.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/cspell/8.15.1/8.15.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
streetsidesoftware/cspell (cspell) ### [`v8.15.2`](https://redirect.github.com/streetsidesoftware/cspell/blob/HEAD/packages/cspell/CHANGELOG.md#small8152-2024-10-14-small) [Compare Source](https://redirect.github.com/streetsidesoftware/cspell/compare/v8.15.1...v8.15.2) - chore: Update Integration Test Performance Data ([#​6361](https://redirect.github.com/streetsidesoftware/cspell/issues/6361)) ([d639368](https://redirect.github.com/streetsidesoftware/cspell/commit/d639368)), closes [#​6361](https://redirect.github.com/streetsidesoftware/cspell/issues/6361)
--- ### Configuration 📅 **Schedule**: Branch creation - "after 10pm every weekday,before 5am every weekday,every weekend" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/nobl9/nobl9-go). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b73d1800..7873aff6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "devDependencies": { - "cspell": "8.15.1", + "cspell": "8.15.2", "markdownlint-cli": "0.41.0", "yaml": "2.6.0" }, From 07d9bba110136827fdcc2cf08902229b3dd9f223 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus <48822818+nieomylnieja@users.noreply.github.com> Date: Tue, 15 Oct 2024 10:58:42 +0200 Subject: [PATCH 2/4] feat: Add standardized API errors (#567) ## Summary Add application level errors to the current `APIError`. Due to the naming conflict, rename `APIError` to more general `HTTPError` and name the application level error `APIError`. ## Release Notes `sdk.APIError` is now an application level error. It can be accessed through `sdk.HTTPError.Errors` field. ## Breaking Changes The former `sdk.APIError` has been renamed `sdk.HTTPError`. The `sdk.HTTPError.Message` field has been removed. Run `gofmt -r 'sdk.APIError -> sdk.HTTPError'` to apply the change to your codebase. --- sdk/api_error.tmpl | 1 - sdk/client_errors.go | 80 ----------- sdk/client_errors_test.go | 127 ----------------- sdk/http_error.tmpl | 8 ++ sdk/http_errors.go | 139 +++++++++++++++++++ sdk/http_errors_test.go | 279 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 426 insertions(+), 208 deletions(-) delete mode 100644 sdk/api_error.tmpl delete mode 100644 sdk/client_errors.go delete mode 100644 sdk/client_errors_test.go create mode 100644 sdk/http_error.tmpl create mode 100644 sdk/http_errors.go create mode 100644 sdk/http_errors_test.go diff --git a/sdk/api_error.tmpl b/sdk/api_error.tmpl deleted file mode 100644 index 187b041e..00000000 --- a/sdk/api_error.tmpl +++ /dev/null @@ -1 +0,0 @@ -{{- if .CodeText }}{{ .CodeText }}: {{ end -}}{{ .Message }} (code: {{ .Code }}{{- if .URL }}, endpoint: {{ .Method }} {{ .URL }}{{- end }}{{- if .TraceID }}, traceId: {{ .TraceID }}{{- end }}) \ No newline at end of file diff --git a/sdk/client_errors.go b/sdk/client_errors.go deleted file mode 100644 index e849e61d..00000000 --- a/sdk/client_errors.go +++ /dev/null @@ -1,80 +0,0 @@ -package sdk - -import ( - "bytes" - _ "embed" - "fmt" - "io" - "net/http" - "os" - "text/template" -) - -// APIError represents an HTTP error response from the API. -type APIError struct { - Message string `json:"message"` - StatusCode int `json:"statusCode"` - Method string `json:"method"` - URL string `json:"url"` - TraceID string `json:"traceId,omitempty"` -} - -// IsRetryable returns true if the underlying API error can be retried. -func (r APIError) IsRetryable() bool { - return r.StatusCode >= 500 -} - -// Error returns a string representation of the error. -func (r APIError) Error() string { - buf := bytes.Buffer{} - buf.Grow(len(apiErrorTemplateData)) - if err := apiErrorTemplate.Execute(&buf, apiErrorTemplateFields{ - Message: r.Message, - Method: r.Method, - URL: r.URL, - TraceID: r.TraceID, - CodeText: http.StatusText(r.StatusCode), - Code: r.StatusCode, - }); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "failed to execute APIError template: %v\n", err) - } - return buf.String() -} - -// processHTTPResponse processes an HTTP response and returns an error if the response is erroneous. -func processHTTPResponse(resp *http.Response) error { - if resp.StatusCode < 300 { - return nil - } - var body string - if resp.Body != nil { - rawBody, _ := io.ReadAll(resp.Body) - body = string(bytes.TrimSpace(rawBody)) - } - respErr := APIError{ - StatusCode: resp.StatusCode, - TraceID: resp.Header.Get(HeaderTraceID), - Message: body, - } - if resp.Request != nil { - if resp.Request.URL != nil { - respErr.URL = resp.Request.URL.String() - } - respErr.Method = resp.Request.Method - } - return &respErr -} - -//go:embed api_error.tmpl -var apiErrorTemplateData string - -var apiErrorTemplate = template.Must(template.New("api_error").Parse(apiErrorTemplateData)) - -type apiErrorTemplateFields struct { - Message string - Method string - URL string - TraceID string - CodeText string - Code int -} diff --git a/sdk/client_errors_test.go b/sdk/client_errors_test.go deleted file mode 100644 index baad6898..00000000 --- a/sdk/client_errors_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package sdk - -import ( - "bytes" - "fmt" - "io" - "net/http" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAPIError(t *testing.T) { - t.Parallel() - t.Run("status code smaller than 300, no error", func(t *testing.T) { - t.Parallel() - for code := 200; code < 300; code++ { - require.NoError(t, processHTTPResponse(&http.Response{StatusCode: code})) - } - }) - t.Run("errors", func(t *testing.T) { - t.Parallel() - for code := 300; code < 600; code++ { - err := processHTTPResponse(&http.Response{ - StatusCode: code, - Body: io.NopCloser(bytes.NewBufferString("error!")), - Header: http.Header{HeaderTraceID: []string{"123"}}, - Request: &http.Request{ - Method: http.MethodGet, - URL: &url.URL{ - Scheme: "https", - Host: "app.nobl9.com", - Path: "/api/slos", - }, - }, - }) - require.Error(t, err) - expectedMessage := fmt.Sprintf("error! (code: %d, endpoint: GET https://app.nobl9.com/api/slos, traceId: 123)", code) - if textCode := http.StatusText(code); textCode != "" { - expectedMessage = textCode + ": " + expectedMessage - } - require.EqualError(t, err, expectedMessage) - } - }) - t.Run("missing trace id", func(t *testing.T) { - t.Parallel() - err := processHTTPResponse(&http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(bytes.NewBufferString("error!")), - Request: &http.Request{ - Method: http.MethodGet, - URL: &url.URL{ - Scheme: "https", - Host: "app.nobl9.com", - Path: "/api/slos", - }, - }, - }) - require.Error(t, err) - expectedMessage := "Bad Request: error! (code: 400, endpoint: GET https://app.nobl9.com/api/slos)" - require.EqualError(t, err, expectedMessage) - }) - t.Run("missing status text", func(t *testing.T) { - t.Parallel() - err := processHTTPResponse(&http.Response{ - StatusCode: 555, - Body: io.NopCloser(bytes.NewBufferString("error!")), - Request: &http.Request{ - Method: http.MethodGet, - URL: &url.URL{ - Scheme: "https", - Host: "app.nobl9.com", - Path: "/api/slos", - }, - }, - }) - require.Error(t, err) - expectedMessage := "error! (code: 555, endpoint: GET https://app.nobl9.com/api/slos)" - require.EqualError(t, err, expectedMessage) - }) - t.Run("missing url", func(t *testing.T) { - t.Parallel() - err := processHTTPResponse(&http.Response{ - StatusCode: 555, - Body: io.NopCloser(bytes.NewBufferString("error!")), - }) - require.Error(t, err) - expectedMessage := "error! (code: 555)" - require.EqualError(t, err, expectedMessage) - }) -} - -func TestAPIError_IsRetryable(t *testing.T) { - t.Parallel() - tests := []*http.Response{ - { - StatusCode: http.StatusInternalServerError, - Body: io.NopCloser(bytes.NewBufferString("operation failed due to concurrency issue but can be retried")), - Request: &http.Request{ - Method: http.MethodPut, - URL: &url.URL{ - Scheme: "https", - Host: "app.nobl9.com", - Path: "/api/apply", - }, - }, - }, - { - StatusCode: http.StatusInternalServerError, - Request: &http.Request{ - Method: http.MethodPut, - URL: &url.URL{ - Scheme: "https", - Host: "app.nobl9.com", - Path: "/api/apply", - }, - }, - }, - } - for _, test := range tests { - err := processHTTPResponse(test) - require.Error(t, err) - assert.True(t, err.(*APIError).IsRetryable()) - } -} diff --git a/sdk/http_error.tmpl b/sdk/http_error.tmpl new file mode 100644 index 00000000..b950811c --- /dev/null +++ b/sdk/http_error.tmpl @@ -0,0 +1,8 @@ +{{- if and (len .Errors | eq 1) (not (index .Errors 0).Source) }} + {{- if .CodeText }}{{ .CodeText }}: {{ end -}}{{ (index .Errors 0).Title }} (code: {{ .Code }}{{- if .URL }}, endpoint: {{ .Method }} {{ .URL }}{{- end }}{{- if .TraceID }}, traceId: {{ .TraceID }}{{- end }}) +{{- else }} + {{- if .CodeText }}{{ .CodeText }} {{ end -}} (code: {{ .Code }}{{- if .URL }}, endpoint: {{ .Method }} {{ .URL }}{{- end }}{{- if .TraceID }}, traceId: {{ .TraceID }}{{- end }}) + {{- range .Errors }} + - {{ .Title }}{{- if .Source }} (source: '{{ .Source.PropertyName }}'{{- if .Source.PropertyValue }}, value: '{{ .Source.PropertyValue }}'{{- end }}){{- end }} + {{- end }} +{{- end }} diff --git a/sdk/http_errors.go b/sdk/http_errors.go new file mode 100644 index 00000000..eb7d25c6 --- /dev/null +++ b/sdk/http_errors.go @@ -0,0 +1,139 @@ +package sdk + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "text/template" + + "github.com/pkg/errors" +) + +// HTTPError represents an HTTP error response from the API. +type HTTPError struct { + // StatusCode is the HTTP status code of the response. + // Example: 200, 400, 404, 500. + StatusCode int `json:"statusCode"` + // Method is the HTTP method used to make the request. + // Example: "GET", "POST", "PUT", "DELETE". + Method string `json:"method"` + // URL is the URL of the API endpoint that was called. + URL string `json:"url"` + // TraceID is an optional, unique identifier that can be used to trace the error in Nobl9 platform. + // Contact [Nobl9 support] if you need help debugging the issue based on the TraceID. + // + // [Nobl9 support]: https://nobl9.com/contact/support + TraceID string `json:"traceId,omitempty"` + // Errors is a list of errors returned by the API. + // At least one error is always guaranteed to be set. + // At the very minimum it will contain just the [APIError.Title]. + Errors []APIError `json:"errors"` +} + +// APIError defines a standardized format for error responses across all Nobl9 public services. +// It ensures that errors are communicated in a consistent and structured manner, +// making it easier for developers to handle and debug issues. +type APIError struct { + // Title is a human-readable summary of the error. It is required. + Title string `json:"title"` + // Code is an application-specific error code. It is optional. + Code string `json:"code,omitempty"` + // Source provides additional context for the source of the error. It is optional. + Source *APIErrorSource `json:"source,omitempty"` +} + +// APIErrorSource provides additional context for the source of the [APIError]. +type APIErrorSource struct { + // PropertyName is an optional name of the property that caused the error. + // It can be a JSON path or a simple property name. + PropertyName string `json:"propertyName,omitempty"` + // PropertyValue is an optional value of the property that caused the error. + PropertyValue string `json:"propertyValue,omitempty"` +} + +// IsRetryable returns true if the underlying API error can be retried. +func (r HTTPError) IsRetryable() bool { + return r.StatusCode >= 500 +} + +// Error returns a string representation of the error. +func (r HTTPError) Error() string { + buf := bytes.Buffer{} + buf.Grow(len(httpErrorTemplateData)) + if err := httpErrorTemplate.Execute(&buf, httpErrorTemplateFields{ + Errors: r.Errors, + Method: r.Method, + URL: r.URL, + TraceID: r.TraceID, + CodeText: http.StatusText(r.StatusCode), + Code: r.StatusCode, + }); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to execute %T template: %v\n", r, err) + } + return buf.String() +} + +// processHTTPResponse processes an HTTP response and returns an error if the response is erroneous. +func processHTTPResponse(resp *http.Response) error { + if resp.StatusCode < 300 { + return nil + } + apiErrors, err := processAPIErrors(resp) + if err != nil { + return err + } + httpErr := HTTPError{ + StatusCode: resp.StatusCode, + TraceID: resp.Header.Get(HeaderTraceID), + Errors: apiErrors, + } + if resp.Request != nil { + if resp.Request.URL != nil { + httpErr.URL = resp.Request.URL.String() + } + httpErr.Method = resp.Request.Method + } + return &httpErr +} + +// processAPIErrors processes an HTTP response and returns a list of [APIError]. +// It checks for the 'content-type' header, if it's set to 'application/json' +// it will decode the response body directly into a slice of [APIError]. +// Otherwise, a single [APIError] is created with the response body as the [APIError.Title]. +func processAPIErrors(resp *http.Response) ([]APIError, error) { + if resp.Body == nil { + return []APIError{{Title: "unknown error"}}, nil + } + if typ := resp.Header.Get("Content-Type"); typ != "" && strings.HasPrefix(typ, "application/json") { + dec := json.NewDecoder(resp.Body) + var apiErrors []APIError + if err := dec.Decode(&apiErrors); err != nil { + return nil, errors.Wrap(err, "failed to decode JSON response body") + } + return apiErrors, nil + } + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response body") + } + return []APIError{{Title: string(bytes.TrimSpace(rawBody))}}, nil +} + +//go:embed http_error.tmpl +var httpErrorTemplateData string + +var httpErrorTemplate = template.Must(template.New("").Parse(strings.TrimSpace(httpErrorTemplateData))) + +type httpErrorTemplateFields struct { + Errors []APIError + Method string + URL string + TraceID string + CodeText string + Code int +} diff --git a/sdk/http_errors_test.go b/sdk/http_errors_test.go new file mode 100644 index 00000000..191d3d11 --- /dev/null +++ b/sdk/http_errors_test.go @@ -0,0 +1,279 @@ +package sdk + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPError(t *testing.T) { + t.Parallel() + t.Run("status code smaller than 300, no error", func(t *testing.T) { + t.Parallel() + for code := 200; code < 300; code++ { + require.NoError(t, processHTTPResponse(&http.Response{StatusCode: code})) + } + }) + t.Run("errors", func(t *testing.T) { + t.Parallel() + for code := 300; code < 600; code++ { + err := processHTTPResponse(&http.Response{ + StatusCode: code, + Body: io.NopCloser(bytes.NewBufferString("error!")), + Header: http.Header{HeaderTraceID: []string{"123"}}, + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/slos", + }, + }, + }) + require.Error(t, err) + expectedError := &HTTPError{ + StatusCode: code, + Method: "GET", + URL: "https://app.nobl9.com/api/slos", + TraceID: "123", + Errors: []APIError{{Title: "error!"}}, + } + require.Equal(t, expectedError, err) + expectedMessage := fmt.Sprintf("error! (code: %d, endpoint: GET https://app.nobl9.com/api/slos, traceId: 123)", code) + if textCode := http.StatusText(code); textCode != "" { + expectedMessage = textCode + ": " + expectedMessage + } + require.EqualError(t, err, expectedMessage) + } + }) + t.Run("missing trace id", func(t *testing.T) { + t.Parallel() + err := processHTTPResponse(&http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString("error!")), + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/slos", + }, + }, + }) + require.Error(t, err) + expectedError := &HTTPError{ + StatusCode: 400, + Method: "GET", + URL: "https://app.nobl9.com/api/slos", + Errors: []APIError{{Title: "error!"}}, + } + require.Equal(t, expectedError, err) + expectedMessage := "Bad Request: error! (code: 400, endpoint: GET https://app.nobl9.com/api/slos)" + assert.EqualError(t, err, expectedMessage) + }) + t.Run("missing status text", func(t *testing.T) { + t.Parallel() + err := processHTTPResponse(&http.Response{ + StatusCode: 555, + Body: io.NopCloser(bytes.NewBufferString("error!")), + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/slos", + }, + }, + }) + require.Error(t, err) + expectedError := &HTTPError{ + StatusCode: 555, + Method: "GET", + URL: "https://app.nobl9.com/api/slos", + Errors: []APIError{{Title: "error!"}}, + } + require.Equal(t, expectedError, err) + expectedMessage := "error! (code: 555, endpoint: GET https://app.nobl9.com/api/slos)" + assert.EqualError(t, err, expectedMessage) + }) + t.Run("missing url", func(t *testing.T) { + t.Parallel() + err := processHTTPResponse(&http.Response{ + StatusCode: 555, + Body: io.NopCloser(bytes.NewBufferString("error!")), + }) + require.Error(t, err) + expectedError := &HTTPError{ + StatusCode: 555, + Errors: []APIError{{Title: "error!"}}, + } + require.Equal(t, expectedError, err) + expectedMessage := "error! (code: 555)" + assert.EqualError(t, err, expectedMessage) + }) + t.Run("missing body", func(t *testing.T) { + t.Parallel() + err := processHTTPResponse(&http.Response{ + StatusCode: http.StatusInternalServerError, + Body: nil, + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/slos", + }, + }, + }) + require.Error(t, err) + expectedMessage := "Internal Server Error: unknown error (code: 500, endpoint: GET https://app.nobl9.com/api/slos)" + assert.EqualError(t, err, expectedMessage) + }) + t.Run("failed to read body", func(t *testing.T) { + t.Parallel() + readerErr := errors.New("reader error") + err := processHTTPResponse(&http.Response{ + StatusCode: http.StatusInternalServerError, + Body: &mockReadCloser{err: readerErr}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, readerErr) + }) + t.Run("read JSON API errors", func(t *testing.T) { + t.Parallel() + apiErrors := []APIError{ + { + Title: "error1", + }, + { + Title: "error2", + Code: "some_code", + }, + { + Title: "error3", + Code: "other_code", + Source: &APIErrorSource{ + PropertyName: "$.data", + }, + }, + { + Title: "error4", + Code: "yet_another_code", + Source: &APIErrorSource{ + PropertyName: "$.data[1].name", + PropertyValue: "value", + }, + }, + } + data, err := json.Marshal(apiErrors) + require.NoError(t, err) + + err = processHTTPResponse(&http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + HeaderTraceID: []string{"123"}, + }, + Body: io.NopCloser(bytes.NewBuffer(data)), + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/slos", + }, + }, + }) + require.Error(t, err) + expectedError := &HTTPError{ + StatusCode: 400, + Method: "GET", + URL: "https://app.nobl9.com/api/slos", + TraceID: "123", + Errors: apiErrors, + } + assert.Equal(t, expectedError, err) + expectedMessage := `Bad Request (code: 400, endpoint: GET https://app.nobl9.com/api/slos, traceId: 123) + - error1 + - error2 + - error3 (source: '$.data') + - error4 (source: '$.data[1].name', value: 'value')` + assert.EqualError(t, err, expectedMessage) + }) + t.Run("failed to read JSON", func(t *testing.T) { + t.Parallel() + err := processHTTPResponse(&http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(`{"this:"that"}`)), + }) + require.Error(t, err) + assert.Error(t, err, "failed to decode JSON response body") + }) + t.Run("content type with charset", func(t *testing.T) { + t.Parallel() + apiErrors := []APIError{{Title: "error"}} + data, err := json.Marshal(apiErrors) + require.NoError(t, err) + + err = processHTTPResponse(&http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}}, + Body: io.NopCloser(bytes.NewBuffer(data)), + }) + require.Error(t, err) + expectedError := &HTTPError{ + StatusCode: 400, + Errors: apiErrors, + } + assert.Equal(t, expectedError, err) + }) +} + +type mockReadCloser struct{ err error } + +func (mo *mockReadCloser) Read(p []byte) (n int, err error) { return 0, mo.err } + +func (mo *mockReadCloser) Close() error { return nil } + +func TestHTTPError_IsRetryable(t *testing.T) { + t.Parallel() + tests := []*http.Response{ + { + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBufferString("operation failed due to concurrency issue but can be retried")), + Request: &http.Request{ + Method: http.MethodPut, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/apply", + }, + }, + }, + { + StatusCode: http.StatusInternalServerError, + Request: &http.Request{ + Method: http.MethodPut, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/apply", + }, + }, + }, + } + for _, test := range tests { + err := processHTTPResponse(test) + require.Error(t, err) + assert.True(t, err.(*HTTPError).IsRetryable()) + } +} From eb9041d592d84d068c8a2c4674d92046d953bf6a Mon Sep 17 00:00:00 2001 From: Mateusz Karasiewicz <100848248+mkaras-nobl9@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:18:17 +0200 Subject: [PATCH 3/4] feat: extend SLO status by most recent process status [PC-14514] (#570) ## Motivation Adds new SLO process statuses to the `status` field. ## Summary before: ``` apiVersion: n9/v1alpha kind: SLO ... status: timeTravel: source: user startTime: 2024-07-22T08:10:38Z status: completed triggeredBy: 00udqsfk2d3Oe8ljW4x6 unit: Day value: 30.0 targetSlo: timeTravelStatus: startTime: 2024-07-21T08:10:38Z status: completed triggeredBy: 00u6axxaftHIMOizO4x7 unit: Day value: 30.0 updatedAt: 2024-10-03T07:52:25Z ``` after: ``` apiVersion: n9/v1alpha kind: SLO ... status: compositeSlo: startTime: 2024-10-14T03:49:06Z status: queued triggeredBy: 00u6axxaftHIMOizO4x7 unit: Hour value: 9.0 errorBudgetAdjustment: startTime: 2024-10-14T01:57:42Z status: queued triggeredBy: 00u6axxaftHIMOizO4x7 unit: Hour value: 10.0 replay: startTime: 2024-10-13T11:20:32Z status: queued triggeredBy: 00u6axxaftHIMOizO4x7 unit: Day value: 1.0 targetSlo: replay: startTime: 2024-07-21T08:10:38Z status: completed triggeredBy: 00u6axxaftHIMOizO4x7 unit: Day value: 30.0 updatedAt: 2024-10-14T11:20:31Z ``` ## Related changes https://github.com/nobl9/n9/pull/15672 ## Breaking change SLO kind: - The `status.timeTravel` field has been renamed to `status.replay`. - The `status.timeTravel.source` field has been removed. - The `status.targetSlo.timeTravelStatus` field has been renamed to `status.targetSlo.replay`. ## Release Notes Added new fields for SLO kind status, which reflect the current state of the last process for the given type - replay, errorBudgetAdjustment, compositeSlo. --- manifest/v1alpha/slo/slo.go | 25 +++++++++++++++++++++---- sdk/models/replay.go | 12 ++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/manifest/v1alpha/slo/slo.go b/manifest/v1alpha/slo/slo.go index 88d86f8a..2176c757 100644 --- a/manifest/v1alpha/slo/slo.go +++ b/manifest/v1alpha/slo/slo.go @@ -161,15 +161,32 @@ type AnomalyConfigAlertMethod struct { // Status holds dynamic fields returned when the Service is fetched from Nobl9 platform. // Status is not part of the static object definition. type Status struct { - UpdatedAt string `json:"updatedAt,omitempty"` - ReplayStatus *ReplayStatus `json:"timeTravel,omitempty"` - TargetSLOStatus *TargetSloStatus `json:"targetSlo,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + CompositeSLO *ProcessStatus `json:"compositeSlo,omitempty"` + ErrorBudgetAdjustment *ProcessStatus `json:"errorBudgetAdjustment,omitempty"` + Replay *ProcessStatus `json:"replay,omitempty"` + TargetSLO *TargetSloStatus `json:"targetSlo,omitempty"` + // Deprecated: use Status.Replay instead. + ReplayStatus *ReplayStatus `json:"timeTravel,omitempty"` } +type ProcessStatus struct { + Status string `json:"status"` + TriggeredBy string `json:"triggeredBy"` + Unit string `json:"unit"` + Value int `json:"value"` + StartTime string `json:"startTime"` +} + +// TargetSloStatus represents the status of Replay a target SLO process. type TargetSloStatus struct { - TargetTimeTravel ReplayStatus `json:"targetTimeTravel"` + // Deprecated: use TargetSloStatus.Replay instead. + TargetTimeTravel ReplayStatus `json:"targetTimeTravel,omitempty"` + Replay ProcessStatus `json:"replay,omitempty"` } +// Deprecated: ReplayStatus exists for historical compatibility +// and should not be used. type ReplayStatus struct { Source string `json:"source"` Status string `json:"status"` diff --git a/sdk/models/replay.go b/sdk/models/replay.go index f57593ee..d96ba44e 100644 --- a/sdk/models/replay.go +++ b/sdk/models/replay.go @@ -9,6 +9,8 @@ import ( "github.com/nobl9/govy/pkg/govy" "github.com/nobl9/govy/pkg/rules" + + "github.com/nobl9/nobl9-go/manifest/v1alpha/slo" ) // maximumAllowedReplayDuration currently is 30 days. @@ -49,6 +51,16 @@ type ReplayStatus struct { StartTime string `json:"startTime"` } +func ToProcessStatus(status ReplayStatus) slo.ProcessStatus { + return slo.ProcessStatus{ + Status: status.Status, + TriggeredBy: status.TriggeredBy, + Unit: status.Unit, + Value: status.Value, + StartTime: status.StartTime, + } +} + type ReplaySourceSLO struct { Slo string `json:"slo"` Project string `json:"project"` From 24782da0f04759accc905bfc73f4886c79623939 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 02:22:39 +0000 Subject: [PATCH 4/4] chore: Update dependency cspell to v8.15.3 (#573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [cspell](https://cspell.org/) ([source](https://redirect.github.com/streetsidesoftware/cspell/tree/HEAD/packages/cspell)) | [`8.15.2` -> `8.15.3`](https://renovatebot.com/diffs/npm/cspell/8.15.2/8.15.3) | [![age](https://developer.mend.io/api/mc/badges/age/npm/cspell/8.15.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/cspell/8.15.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/cspell/8.15.2/8.15.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/cspell/8.15.2/8.15.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
streetsidesoftware/cspell (cspell) ### [`v8.15.3`](https://redirect.github.com/streetsidesoftware/cspell/blob/HEAD/packages/cspell/CHANGELOG.md#small8153-2024-10-16-small) [Compare Source](https://redirect.github.com/streetsidesoftware/cspell/compare/v8.15.2...v8.15.3) - chore: Update Integration Test Performance Data ([#​6377](https://redirect.github.com/streetsidesoftware/cspell/issues/6377)) ([7ff6781](https://redirect.github.com/streetsidesoftware/cspell/commit/7ff6781)), closes [#​6377](https://redirect.github.com/streetsidesoftware/cspell/issues/6377)
--- ### Configuration 📅 **Schedule**: Branch creation - "after 10pm every weekday,before 5am every weekday,every weekend" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/nobl9/nobl9-go). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7873aff6..7247a04f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "devDependencies": { - "cspell": "8.15.2", + "cspell": "8.15.3", "markdownlint-cli": "0.41.0", "yaml": "2.6.0" },