Repository: opensearch-project/opensearch-go
Pull request #512 (2024-04-05 14:35:07)

Move error structs from opensearchapi to opensearch to be reused by plugins
### Description
- Move error structs from opensearchapi to opensearch including tests
- Add new errors structs
- Move parseError function from opensearchapi to opensearch error.go as ParseError
- Change ParseError function to do type assertion to determine the correct error type
- Adjust tests to new errors
- Handle non json errors

This is a braking change as the error types are moved to another project and also get renamed.
Needed as preparation for plugin integrations.

### Issues Resolved
_List any issues this PR will resolve, e.g. Closes [...]._ 

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check [here](https://github.com/opensearch-project/OpenSearch/blob/main/CONTRIBUTING.md#developer-certificate-of-origin).


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/opensearch-project/opensearch-go/pull/512?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project) Report
Attention: Patch coverage is `96.34146%` with `3 lines` in your changes are missing coverage. Please review.
> Project coverage is 57.41%. Comparing base [(`f3ad333`)](https://app.codecov.io/gh/opensearch-project/opensearch-go/commit/f3ad33348b9b61f17bf339f323667cc8e9280782?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project) to head [(`4bfa07c`)](https://app.codecov.io/gh/opensearch-project/opensearch-go/pull/512?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #512      +/-   ##
==========================================
+ Coverage   57.29%   57.41%   +0.12%     
==========================================
  Files         315      315              
  Lines        9823     9859      +36     
==========================================
+ Hits         5628     5661      +33     
- Misses       2902     2904       +2     
- Partials     1293     1294       +1     
```

| [Flag](https://app.codecov.io/gh/opensearch-project/opensearch-go/pull/512/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project) | Coverage Δ | |
|---|---|---|
| [integration](https://app.codecov.io/gh/opensearch-project/opensearch-go/pull/512/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project) | `50.40% <1.21%> (-0.44%)` | :arrow_down: |
| [unit](https://app.codecov.io/gh/opensearch-project/opensearch-go/pull/512/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project) | `77.52% <96.29%> (+64.67%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/opensearch-project/opensearch-go/pull/512?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project) | Coverage Δ | |
|---|---|---|
| [opensearchapi/opensearchapi.go](https://app.codecov.io/gh/opensearch-project/opensearch-go/pull/512?src=pr&el=tree&filepath=opensearchapi%2Fopensearchapi.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project#diff-b3BlbnNlYXJjaGFwaS9vcGVuc2VhcmNoYXBpLmdv) | `80.95% <100.00%> (-4.42%)` | :arrow_down: |
| [error.go](https://app.codecov.io/gh/opensearch-project/opensearch-go/pull/512?src=pr&el=tree&filepath=error.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=opensearch-project#diff-ZXJyb3IuZ28=) | `96.29% <96.29%> (ø)` | |

</details>
Jakob3xD: > Looks like a publicly-facing backwards-incompatible breaking change? Unless you want to inherit these error types in old names and mark those deprecated, let's bump the version to 4 as part of it?

I adjusted the upgrading.md to version 4.0.0. Do you want the release done in this PR or after all my other PRs are merged?
I'd prefer after all the others are merged.
dblock: > > Looks like a publicly-facing backwards-incompatible breaking change? Unless you want to inherit these error types in old names and mark those deprecated, let's bump the version to 4 as part of it?
> 
> I adjusted the upgrading.md to version 4.0.0. Do you want the release done in this PR or after all my other PRs are merged? I'd prefer after all the others are merged.

After. Usually the version number should be set on working code as early as possible so that nobody confuses it with released code of the previous version.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e6f6d6..765f8e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,10 +8,15 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
 - Adds GlobalIOUsage struct for nodes stats ([#506](https://github.com/opensearch-project/opensearch-go/pull/506))
 - Adds the `Explanation` field containing the document explain details to the `SearchHit` struct. ([#504](https://github.com/opensearch-project/opensearch-go/pull/504))
 - Adds the `Fields` field containing the document fields to the `SearchHit` struct. ([#508](https://github.com/opensearch-project/opensearch-go/pull/508))
+- Adds new error types ([#512](https://github.com/opensearch-project/opensearch-go/pull/506))
+- Adds handling of non json errors to ParseError ([#512](https://github.com/opensearch-project/opensearch-go/pull/506))
 
 ### Changed
 - Use docker compose v2 instead of v1 ([#506](https://github.com/opensearch-project/opensearch-go/pull/506))
 - Updates go version to 1.21 ([#509](https://github.com/opensearch-project/opensearch-go/pull/509))
+- Moved Error structs from opensearchapi to opensearch package ([#512](https://github.com/opensearch-project/opensearch-go/pull/506))
+- Move parseError function from opensearchapi to opensearch package as ParseError ([#512](https://github.com/opensearch-project/opensearch-go/pull/506))
+- Change ParseError function to do type assertion to determine error type ([#512](https://github.com/opensearch-project/opensearch-go/pull/506))
 
 ### Deprecated
 
diff --git a/UPGRADING.md b/UPGRADING.md
index c80a6f0..e9d3e74 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -1,14 +1,35 @@
 - [Upgrading Opensearch GO Client](#upgrading-opensearch-go-client)
-  - [Upgraading to >= 3.0.0](#upgrading-to->=-3.0.0)
+  - [Upgrading to >= 4.0.0](#upgrading-to->=-4.0.0)
+    - [error types](#error-types)
+  - [Upgrading to >= 3.0.0](#upgrading-to->=-3.0.0)
     - [client creation](#client-creation)
     - [requests](#requests)
     - [responses](#responses)
     - [error handing](#error-handling)
-  - [Upgraading to >= 2.3.0](#upgrading-to->=-2.3.0)
+  - [Upgrading to >= 2.3.0](#upgrading-to->=-2.3.0)
     - [snapshot delete](#snapshot-delete)
 
 # Upgrading Opensearch GO Client
 
+## Upgrading to >= 4.0.0
+
+Version 4.0.0 moved the error types, added with 3.0.0, from opensearchapi to opensearch, renamed them and added new error types.
+
+### Error Types
+
+Before 4.0.0:
+Error types:
+- `opensearchapi.Error`
+- `opensearchapi.StringError`
+
+With 4.0.0:
+Error types
+- `opensearch.Error`
+- `opensearch.StringError`
+- `opensearch.ReasonError`
+- `opensearch.MessageError`
+- `opensearch.StructError` (which was the `opensearchapi.Error`)
+
 ## Upgrading to >= 3.0.0
 
 Version 3.0.0 is a major refactor of the client.
diff --git a/error.go b/error.go
new file mode 100644
index 0000000..5048f4b
--- /dev/null
+++ b/error.go
@@ -0,0 +1,192 @@
+// SPDX-License-Identifier: Apache-2.0
+//
+// The OpenSearch Contributors require contributions made to
+// this file be licensed under the Apache-2.0 license or a
+// compatible open source license.
+
+package opensearch
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+)
+
+// Error vars
+var (
+	ErrUnexpectedEmptyBody    = errors.New("body is unexpectedly empty")
+	ErrReadBody               = errors.New("failed to read body")
+	ErrJSONUnmarshalBody      = errors.New("failed to json unmarshal body")
+	ErrUnknownOpensearchError = errors.New("opensearch error response could not be parsed as error")
+	ErrNonJSONError           = errors.New("error is not in json format")
+	ErrUnauthorized           = errors.New(http.StatusText(http.StatusUnauthorized))
+)
+
+// Error represents an Opensearch error with only an error field
+type Error struct {
+	Err string `json:"error"`
+}
+
+// Error returns a string
+func (e Error) Error() string {
+	return fmt.Sprintf("error: %s", e.Err)
+}
+
+// StringError represnets an Opensearch error where error is a string
+type StringError struct {
+	Err    string `json:"error"`
+	Status int    `json:"status"`
+}
+
+// Error returns a string
+func (e StringError) Error() string {
+	return fmt.Sprintf("status: %d, error: %s", e.Status, e.Err)
+}
+
+// ReasonError represents an Opensearch error with a reason field
+type ReasonError struct {
+	Reason string `json:"reason"`
+	Status string `json:"status"`
+}
+
+// Error returns a string
+func (e ReasonError) Error() string {
+	return fmt.Sprintf("status: %s, reason: %s", e.Status, e.Reason)
+}
+
+// MessageError represents an Opensearch error with a message field
+type MessageError struct {
+	Message string `json:"message"`
+	Status  string `json:"status"`
+}
+
+// Error returns a string
+func (e MessageError) Error() string {
+	return fmt.Sprintf("status: %s, message: %s", e.Status, e.Message)
+}
+
+// StructError represents an Opensearch error with a detailed error struct
+type StructError struct {
+	Err    Err `json:"error"`
+	Status int `json:"status"`
+}
+
+// Err represents the error of an API error response
+type Err struct {
+	RootCause []RootCause `json:"root_cause"`
+	Type      string      `json:"type"`
+	Reason    string      `json:"reason"`
+	Index     string      `json:"index,omitempty"`
+	IndexUUID string      `json:"index_uuid,omitempty"`
+}
+
+// RootCause represents the root_cause of an API error response
+type RootCause struct {
+	Type      string `json:"type"`
+	Reason    string `json:"reason"`
+	Index     string `json:"index,omitempty"`
+	IndexUUID string `json:"index_uuid,omitempty"`
+}
+
+// Error returns a string
+func (e StructError) Error() string {
+	return fmt.Sprintf("status: %d, type: %s, reason: %s, root_cause: %s", e.Status, e.Err.Type, e.Err.Reason, e.Err.RootCause)
+}
+
+// UnmarshalJSON is a custom unmarshal function for StructError returning custom errors in special cases
+func (e *StructError) UnmarshalJSON(b []byte) error {
+	var dummy struct {
+		Err    json.RawMessage `json:"error"`
+		Status int             `json:"status"`
+	}
+	if err := json.Unmarshal(b, &dummy); err != nil {
+		return err
+	}
+
+	var osErr Err
+	if err := json.Unmarshal(dummy.Err, &osErr); err != nil {
+		return &StringError{Status: dummy.Status, Err: string(dummy.Err)}
+	}
+	if dummy.Status == 0 || (osErr.Type == "" && osErr.Reason == "") {
+		return fmt.Errorf("%w: %s", ErrUnknownOpensearchError, b)
+	}
+
+	e.Err = osErr
+	e.Status = dummy.Status
+
+	return nil
+}
+
+// ParseError tries to parse the opensearch error into an custom error
+func ParseError(resp *Response) error {
+	if resp.Body == nil {
+		return fmt.Errorf("%w, status: %s", ErrUnexpectedEmptyBody, resp.Status())
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return fmt.Errorf("%w: %w", ErrReadBody, err)
+	}
+
+	if !json.Valid(body) {
+		if resp.StatusCode == http.StatusUnauthorized {
+			return ErrUnauthorized
+		}
+		return ErrNonJSONError
+	}
+
+	var testResp struct {
+		Status  any `json:"status"`
+		Error   any `json:"error"`
+		Message any `json:"message"`
+		Reason  any `json:"reason"`
+	}
+	if err = json.Unmarshal(body, &testResp); err != nil {
+		return fmt.Errorf("%w: %w", ErrJSONUnmarshalBody, err)
+	}
+
+	// Check for errors where status is a number
+	if _, ok := testResp.Status.(float64); ok {
+		// Check for errors where error is a string
+		if _, ok := testResp.Error.(string); ok {
+			var apiError StringError
+			return parseError(body, &apiError)
+		}
+		// Check for errors where error is a struct
+		if _, ok := testResp.Error.(map[string]any); ok {
+			var apiError StructError
+			return parseError(body, &apiError)
+		}
+	}
+
+	// Check for errors where status is a string
+	if _, ok := testResp.Status.(string); ok {
+		// Check for erros where message is a string
+		if _, ok := testResp.Message.(string); ok {
+			var apiError MessageError
+			return parseError(body, &apiError)
+		}
+		// Check for errors where reason is a string
+		if _, ok := testResp.Reason.(string); ok {
+			var apiError ReasonError
+			return parseError(body, &apiError)
+		}
+	}
+
+	// Check for errors that only contain the error field
+	if testResp.Status == nil && testResp.Message == nil && testResp.Reason == nil && testResp.Error != nil {
+		var apiError Error
+		return parseError(body, &apiError)
+	}
+
+	return fmt.Errorf("%w: %s", ErrUnknownOpensearchError, string(body))
+}
+
+func parseError(body []byte, errStruct error) error {
+	if err := json.Unmarshal(body, &errStruct); err != nil {
+		return fmt.Errorf("%w: %w: %s", ErrJSONUnmarshalBody, err, string(body))
+	}
+	return errStruct
+}
diff --git a/opensearchapi/error.go b/opensearchapi/error.go
deleted file mode 100644
index e08f11c..0000000
--- a/opensearchapi/error.go
+++ /dev/null
@@ -1,88 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-//
-// The OpenSearch Contributors require contributions made to
-// this file be licensed under the Apache-2.0 license or a
-// compatible open source license.
-
-package opensearchapi
-
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-)
-
-// Error vars
-var (
-	ErrUnexpectedEmptyBody    = errors.New("body is unexpectedly empty")
-	ErrReadBody               = errors.New("failed to read body")
-	ErrJSONUnmarshalBody      = errors.New("failed to json unmarshal body")
-	ErrUnknownOpensearchError = errors.New("opensearch error response could not be parsed as error")
-)
-
-// Error represents the Opensearch API error response
-type Error struct {
-	Err    Err `json:"error"`
-	Status int `json:"status"`
-}
-
-// Err represents the error of an API error response
-type Err struct {
-	RootCause []RootCause `json:"root_cause"`
-	Type      string      `json:"type"`
-	Reason    string      `json:"reason"`
-	Index     string      `json:"index,omitempty"`
-	IndexUUID string      `json:"index_uuid,omitempty"`
-}
-
-// RootCause represents the root_cause of an API error response
-type RootCause struct {
-	Type      string `json:"type"`
-	Reason    string `json:"reason"`
-	Index     string `json:"index,omitempty"`
-	IndexUUID string `json:"index_uuid,omitempty"`
-}
-
-// StringError represnets an Opensearch API Error with a string as error
-type StringError struct {
-	Err    string `json:"error"`
-	Status int    `json:"status"`
-}
-
-// Error returns a string
-func (e Error) Error() string {
-	return fmt.Sprintf("status: %d, type: %s, reason: %s, root_cause: %s", e.Status, e.Err.Type, e.Err.Reason, e.Err.RootCause)
-}
-
-// Error returns a string
-func (e StringError) Error() string {
-	return fmt.Sprintf("status: %d, error: %s", e.Status, e.Err)
-}
-
-// UnmarshalJSON is a custom unmarshal function for Error returning custom errors in special cases
-func (e *Error) UnmarshalJSON(b []byte) error {
-	var dummy struct {
-		Err    json.RawMessage `json:"error"`
-		Status int             `json:"status"`
-	}
-	if err := json.Unmarshal(b, &dummy); err != nil {
-		return fmt.Errorf("%w: %s", err, b)
-	}
-
-	if len(dummy.Err) == 0 {
-		return fmt.Errorf("%w: %s", ErrUnknownOpensearchError, b)
-	}
-
-	var osErr Err
-	if err := json.Unmarshal(dummy.Err, &osErr); err != nil {
-		return StringError{Status: dummy.Status, Err: string(dummy.Err)}
-	}
-	if dummy.Status == 0 || (osErr.Type == "" && osErr.Reason == "") {
-		return fmt.Errorf("%w: %s", ErrUnknownOpensearchError, b)
-	}
-
-	e.Err = osErr
-	e.Status = dummy.Status
-
-	return nil
-}
diff --git a/opensearchapi/opensearchapi.go b/opensearchapi/opensearchapi.go
index 933cccc..dabd887 100644
--- a/opensearchapi/opensearchapi.go
+++ b/opensearchapi/opensearchapi.go
@@ -8,10 +8,7 @@ package opensearchapi
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
-	"io"
-	"net/http"
 	"strconv"
 	"time"
 
@@ -109,7 +106,7 @@ func (c *Client) do(ctx context.Context, req opensearch.Request, dataPointer any
 
 	if resp.IsError() {
 		if dataPointer != nil {
-			return resp, parseError(resp)
+			return resp, opensearch.ParseError(resp)
 		} else {
 			return resp, fmt.Errorf("status: %s", resp.Status())
 		}
@@ -118,36 +115,6 @@ func (c *Client) do(ctx context.Context, req opensearch.Request, dataPointer any
 	return resp, nil
 }
 
-// praseError tries to parse the opensearch api error into an custom error
-func parseError(resp *opensearch.Response) error {
-	if resp.Body == nil {
-		return fmt.Errorf("%w, status: %s", ErrUnexpectedEmptyBody, resp.Status())
-	}
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("%w: %w", ErrReadBody, err)
-	}
-
-	if resp.StatusCode == http.StatusMethodNotAllowed {
-		var apiError StringError
-		if err = json.Unmarshal(body, &apiError); err != nil {
-			return fmt.Errorf("%w: %w", ErrJSONUnmarshalBody, err)
-		}
-		return apiError
-	}
-
-	// ToDo: Parse 404 errors separate as they are not in one standard format
-	// https://github.com/opensearch-project/OpenSearch/issues/9988
-
-	var apiError Error
-	if err = json.Unmarshal(body, &apiError); err != nil {
-		return fmt.Errorf("%w: %w", ErrJSONUnmarshalBody, err)
-	}
-
-	return apiError
-}
-
 // formatDuration converts duration to a string in the format
 // accepted by Opensearch.
 func formatDuration(d time.Duration) string {


Test patch:
diff --git a/error_test.go b/error_test.go
new file mode 100644
index 0000000..cd9b466
--- /dev/null
+++ b/error_test.go
@@ -0,0 +1,248 @@
+// SPDX-License-Identifier: Apache-2.0
+//
+// The OpenSearch Contributors require contributions made to
+// this file be licensed under the Apache-2.0 license or a
+// compatible open source license.
+
+//go:build !integration
+
+package opensearch_test
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+	"testing/iotest"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opensearch-project/opensearch-go/v3"
+)
+
+func TestError(t *testing.T) {
+	t.Run("StructError", func(t *testing.T) {
+		t.Run("Ok", func(t *testing.T) {
+			resp := &opensearch.Response{
+				StatusCode: http.StatusBadRequest,
+				Body: io.NopCloser(
+					strings.NewReader(`{
+						"error":{
+							"root_cause":[{
+								"type":"resource_already_exists_exception",
+								"reason":"index [test/HU2mN_RMRXGcS38j3yV-VQ] already exists",
+								"index":"test",
+								"index_uuid":"HU2mN_RMRXGcS38j3yV-VQ"
+							}],
+							"type":"resource_already_exists_exception",
+							"reason":"index [test/HU2mN_RMRXGcS38j3yV-VQ] already exists",
+							"index":"test",
+							"index_uuid":"HU2mN_RMRXGcS38j3yV-VQ"
+						},
+						"status":400
+					}`),
+				),
+			}
+			assert.True(t, resp.IsError())
+			err := opensearch.ParseError(resp)
+			var testError *opensearch.StructError
+			require.True(t, errors.As(err, &testError))
+			assert.Equal(t, http.StatusBadRequest, testError.Status)
+			assert.Equal(t, "resource_already_exists_exception", testError.Err.Type)
+			assert.Equal(t, "index [test/HU2mN_RMRXGcS38j3yV-VQ] already exists", testError.Err.Reason)
+			assert.Equal(t, "test", testError.Err.Index)
+			assert.Equal(t, "HU2mN_RMRXGcS38j3yV-VQ", testError.Err.IndexUUID)
+			assert.NotNil(t, testError.Err.RootCause)
+			assert.Equal(t, "resource_already_exists_exception", testError.Err.RootCause[0].Type)
+			assert.Equal(t, "index [test/HU2mN_RMRXGcS38j3yV-VQ] already exists", testError.Err.RootCause[0].Reason)
+			assert.Equal(t, "test", testError.Err.RootCause[0].Index)
+			assert.Equal(t, "HU2mN_RMRXGcS38j3yV-VQ", testError.Err.RootCause[0].IndexUUID)
+			_ = fmt.Sprintf("%s", err)
+		})
+
+		t.Run("Unmarshal errors", func(t *testing.T) {
+			t.Run("dummy", func(t *testing.T) {
+				reader := io.NopCloser(
+					strings.NewReader(`{
+						"status": "400"
+					}`),
+				)
+				body, err := io.ReadAll(reader)
+				require.Nil(t, err)
+
+				var errStruct *opensearch.StructError
+				err = json.Unmarshal(body, &errStruct)
+				assert.NotNil(t, err)
+
+				var jsonError *json.UnmarshalTypeError
+				assert.True(t, errors.As(err, &jsonError))
+			})
+			t.Run("string", func(t *testing.T) {
+				reader := io.NopCloser(
+					strings.NewReader(`{
+						"error": 0,
+						"status": 500
+					}`),
+				)
+				body, err := io.ReadAll(reader)
+				require.Nil(t, err)
+
+				var errStruct *opensearch.StructError
+				err = json.Unmarshal(body, &errStruct)
+				assert.NotNil(t, err)
+
+				var errStr *opensearch.StringError
+				require.True(t, errors.As(err, &errStr))
+			})
+		})
+	})
+
+	t.Run("StringError", func(t *testing.T) {
+		resp := &opensearch.Response{
+			StatusCode: http.StatusMethodNotAllowed,
+			Body: io.NopCloser(
+				strings.NewReader(`{
+						"error": "Incorrect HTTP method for uri [/_doc] and method [POST], allowed: [HEAD, DELETE, PUT, GET]",
+						"status":405
+					}`),
+			),
+		}
+		assert.True(t, resp.IsError())
+		err := opensearch.ParseError(resp)
+		var testError *opensearch.StringError
+		require.True(t, errors.As(err, &testError))
+		assert.Equal(t, http.StatusMethodNotAllowed, testError.Status)
+		assert.Contains(t, testError.Err, "Incorrect HTTP method for uri")
+		_ = fmt.Sprintf("%s", testError)
+	})
+
+	t.Run("Error", func(t *testing.T) {
+		resp := &opensearch.Response{
+			StatusCode: http.StatusBadRequest,
+			Body: io.NopCloser(
+				strings.NewReader(`{
+					  "error": "no handler found for uri [/_plugins/_security/xxx] and method [GET]"
+					}`),
+			),
+		}
+		assert.True(t, resp.IsError())
+		err := opensearch.ParseError(resp)
+		var testError *opensearch.Error
+		require.True(t, errors.As(err, &testError))
+		assert.Contains(t, testError.Err, "no handler found for uri [/_plugins/_security/xxx] and method [GET]")
+		_ = fmt.Sprintf("%s", testError)
+	})
+
+	t.Run("ReasonError", func(t *testing.T) {
+		resp := &opensearch.Response{
+			StatusCode: http.StatusBadRequest,
+			Body: io.NopCloser(
+				strings.NewReader(`{
+					  "status": "error",
+					  "reason": "Invalid configuration",
+					  "invalid_keys": {
+					    "keys": "dynamic"
+					  }
+					}`),
+			),
+		}
+		assert.True(t, resp.IsError())
+		err := opensearch.ParseError(resp)
+		var testError *opensearch.ReasonError
+		require.True(t, errors.As(err, &testError))
+		assert.Equal(t, "error", testError.Status)
+		assert.Contains(t, testError.Reason, "Invalid configuration")
+		_ = fmt.Sprintf("%s", testError)
+	})
+
+	t.Run("MessageError", func(t *testing.T) {
+		resp := &opensearch.Response{
+			StatusCode: http.StatusBadRequest,
+			Body: io.NopCloser(
+				strings.NewReader(`{"status":"BAD_REQUEST","message":"Wrong request body"}`),
+			),
+		}
+		assert.True(t, resp.IsError())
+		err := opensearch.ParseError(resp)
+		var testError *opensearch.MessageError
+		require.True(t, errors.As(err, &testError))
+		assert.Equal(t, "BAD_REQUEST", testError.Status)
+		assert.Contains(t, testError.Message, "Wrong request body")
+		_ = fmt.Sprintf("%s", testError)
+	})
+
+	t.Run("Unexpected", func(t *testing.T) {
+		cases := []struct {
+			Name         string
+			Resp         *opensearch.Response
+			WantedErrors []error
+		}{
+			{
+				Name: "Non JSON error",
+				Resp: &opensearch.Response{
+					StatusCode: http.StatusMethodNotAllowed,
+					Body:       io.NopCloser(strings.NewReader(`Test - Trigger an error`)),
+				},
+				WantedErrors: []error{opensearch.ErrNonJSONError},
+			},
+			{
+				Name: "error field object",
+				Resp: &opensearch.Response{
+					StatusCode: http.StatusForbidden,
+					Body:       io.NopCloser(strings.NewReader(`{"error":{"test": "test"},"status":403}`)),
+				},
+				WantedErrors: []error{opensearch.ErrJSONUnmarshalBody, opensearch.ErrUnknownOpensearchError},
+			},
+			{
+				Name: "unknown json",
+				Resp: &opensearch.Response{
+					StatusCode: http.StatusNotFound,
+					Body:       io.NopCloser(strings.NewReader(`{"_index":"index","_id":"2","matched":false}`)),
+				},
+				WantedErrors: []error{opensearch.ErrUnknownOpensearchError},
+			},
+			{
+				Name: "unauthorized",
+				Resp: &opensearch.Response{
+					StatusCode: http.StatusUnauthorized,
+					Body:       io.NopCloser(strings.NewReader(http.StatusText(http.StatusUnauthorized))),
+				},
+				WantedErrors: []error{opensearch.ErrUnauthorized},
+			},
+			{
+				Name: "io read error",
+				Resp: &opensearch.Response{
+					StatusCode: http.StatusBadRequest,
+					Body:       io.NopCloser(iotest.ErrReader(errors.New("io reader test"))),
+				},
+				WantedErrors: []error{opensearch.ErrReadBody},
+			},
+			{
+				Name:         "empty body",
+				Resp:         &opensearch.Response{StatusCode: http.StatusBadRequest},
+				WantedErrors: []error{opensearch.ErrUnexpectedEmptyBody},
+			},
+			{
+				Name: "unmarshal error",
+				Resp: &opensearch.Response{
+					StatusCode: http.StatusNotFound,
+					Body:       io.NopCloser(strings.NewReader(`"test"`)),
+				},
+				WantedErrors: []error{opensearch.ErrJSONUnmarshalBody},
+			},
+		}
+
+		for _, tt := range cases {
+			t.Run(tt.Name, func(t *testing.T) {
+				err := opensearch.ParseError(tt.Resp)
+				for _, wantedError := range tt.WantedErrors {
+					assert.True(t, errors.Is(err, wantedError))
+				}
+			})
+		}
+	})
+}
diff --git a/opensearchapi/api_index_test.go b/opensearchapi/api_index_test.go
index 5249f2a..5a984e3 100644
--- a/opensearchapi/api_index_test.go
+++ b/opensearchapi/api_index_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
+	"github.com/opensearch-project/opensearch-go/v3"
 	ostest "github.com/opensearch-project/opensearch-go/v3/internal/test"
 	"github.com/opensearch-project/opensearch-go/v3/opensearchapi"
 	osapitest "github.com/opensearch-project/opensearch-go/v3/opensearchapi/internal/test"
@@ -34,11 +35,10 @@ func TestIndexClient(t *testing.T) {
 	t.Run("Request Empty", func(t *testing.T) {
 		resp, err := client.Index(nil, opensearchapi.IndexReq{})
 		assert.NotNil(t, err)
-		var osError opensearchapi.StringError
-		ok := errors.As(err, &osError)
+		var osError *opensearch.StringError
+		require.True(t, errors.As(err, &osError))
 		assert.Equal(t, http.StatusMethodNotAllowed, osError.Status)
 		assert.Contains(t, osError.Err, "Incorrect HTTP method for uri")
-		assert.True(t, ok)
 		assert.NotNil(t, resp)
 		assert.NotNil(t, resp.Inspect())
 	})
@@ -46,9 +46,8 @@ func TestIndexClient(t *testing.T) {
 	t.Run("Request Index only", func(t *testing.T) {
 		resp, err := client.Index(nil, opensearchapi.IndexReq{Index: index})
 		assert.NotNil(t, err)
-		var osError opensearchapi.Error
-		ok := errors.As(err, &osError)
-		assert.True(t, ok)
+		var osError *opensearch.StructError
+		require.True(t, errors.As(err, &osError))
 		assert.Equal(t, "parse_exception", osError.Err.Type)
 		assert.Equal(t, "request body is required", osError.Err.Reason)
 		assert.NotNil(t, resp)
diff --git a/opensearchapi/error_test.go b/opensearchapi/error_test.go
deleted file mode 100644
index 3822524..0000000
--- a/opensearchapi/error_test.go
+++ /dev/null
@@ -1,156 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-//
-// The OpenSearch Contributors require contributions made to
-// this file be licensed under the Apache-2.0 license or a
-// compatible open source license.
-
-//go:build !integration
-
-//nolint:testpackage // need to use the parseError function defined in opensearchapi.go
-package opensearchapi
-
-import (
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"strings"
-	"testing"
-	"testing/iotest"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-
-	"github.com/opensearch-project/opensearch-go/v3"
-)
-
-func TestError(t *testing.T) {
-	t.Run("Error okay", func(t *testing.T) {
-		resp := &opensearch.Response{
-			StatusCode: http.StatusBadRequest,
-			Body: io.NopCloser(
-				strings.NewReader(`
-					{
-						"error":{
-							"root_cause":[{
-								"type":"resource_already_exists_exception",
-								"reason":"index [test/HU2mN_RMRXGcS38j3yV-VQ] already exists",
-								"index":"test",
-								"index_uuid":"HU2mN_RMRXGcS38j3yV-VQ"
-							}],
-							"type":"resource_already_exists_exception",
-							"reason":"index [test/HU2mN_RMRXGcS38j3yV-VQ] already exists",
-							"index":"test",
-							"index_uuid":"HU2mN_RMRXGcS38j3yV-VQ"
-						},
-						"status":400
-					}`),
-			),
-		}
-		assert.True(t, resp.IsError())
-		err := parseError(resp)
-		var testError Error
-		require.True(t, errors.As(err, &testError))
-		assert.Equal(t, http.StatusBadRequest, testError.Status)
-		assert.Equal(t, "resource_already_exists_exception", testError.Err.Type)
-		assert.Equal(t, "index [test/HU2mN_RMRXGcS38j3yV-VQ] already exists", testError.Err.Reason)
-		assert.Equal(t, "test", testError.Err.Index)
-		assert.Equal(t, "HU2mN_RMRXGcS38j3yV-VQ", testError.Err.IndexUUID)
-		assert.NotNil(t, testError.Err.RootCause)
-		assert.Equal(t, "resource_already_exists_exception", testError.Err.RootCause[0].Type)
-		assert.Equal(t, "index [test/HU2mN_RMRXGcS38j3yV-VQ] already exists", testError.Err.RootCause[0].Reason)
-		assert.Equal(t, "test", testError.Err.RootCause[0].Index)
-		assert.Equal(t, "HU2mN_RMRXGcS38j3yV-VQ", testError.Err.RootCause[0].IndexUUID)
-		_ = fmt.Sprintf("%s", err)
-	})
-
-	t.Run("StringError okay", func(t *testing.T) {
-		resp := &opensearch.Response{
-			StatusCode: http.StatusMethodNotAllowed,
-			Body: io.NopCloser(
-				strings.NewReader(`
-					{
-						"error": "Incorrect HTTP method for uri [/_doc] and method [POST], allowed: [HEAD, DELETE, PUT, GET]",
-						"status":405
-					}`),
-			),
-		}
-		assert.True(t, resp.IsError())
-		err := parseError(resp)
-		var testError StringError
-		require.True(t, errors.As(err, &testError))
-		assert.Equal(t, http.StatusMethodNotAllowed, testError.Status)
-		assert.Contains(t, testError.Err, "Incorrect HTTP method for uri")
-		_ = fmt.Sprintf("%s", testError)
-	})
-	t.Run("Unexpected", func(t *testing.T) {
-		cases := []struct {
-			Name         string
-			Resp         *opensearch.Response
-			WantedErrors []error
-		}{
-			{
-				Name: "response for StringError",
-				Resp: &opensearch.Response{
-					StatusCode: http.StatusMethodNotAllowed,
-					Body:       io.NopCloser(strings.NewReader(`"Test - Trigger an error"`)),
-				},
-				WantedErrors: []error{ErrJSONUnmarshalBody},
-			},
-			{
-				Name: "response string",
-				Resp: &opensearch.Response{
-					StatusCode: http.StatusForbidden,
-					Body:       io.NopCloser(strings.NewReader(`"Test - Trigger an error"`)),
-				},
-				WantedErrors: []error{ErrJSONUnmarshalBody},
-			},
-			{
-				Name: "error field string",
-				Resp: &opensearch.Response{
-					StatusCode: http.StatusForbidden,
-					Body:       io.NopCloser(strings.NewReader(`{"error":"test","status":403}`)),
-				},
-				WantedErrors: []error{ErrJSONUnmarshalBody},
-			},
-			{
-				Name: "error field object",
-				Resp: &opensearch.Response{
-					StatusCode: http.StatusForbidden,
-					Body:       io.NopCloser(strings.NewReader(`{"error":{"test": "test"},"status":403}`)),
-				},
-				WantedErrors: []error{ErrJSONUnmarshalBody, ErrUnknownOpensearchError},
-			},
-			{
-				Name: "response json",
-				Resp: &opensearch.Response{
-					StatusCode: http.StatusNotFound,
-					Body:       io.NopCloser(strings.NewReader(`{"_index":"index","_id":"2","matched":false}`)),
-				},
-				WantedErrors: []error{ErrJSONUnmarshalBody, ErrJSONUnmarshalBody},
-			},
-			{
-				Name: "io read error",
-				Resp: &opensearch.Response{
-					StatusCode: http.StatusBadRequest,
-					Body:       io.NopCloser(iotest.ErrReader(errors.New("io reader test"))),
-				},
-				WantedErrors: []error{ErrReadBody},
-			},
-			{
-				Name:         "empty body",
-				Resp:         &opensearch.Response{StatusCode: http.StatusBadRequest},
-				WantedErrors: []error{ErrUnexpectedEmptyBody},
-			},
-		}
-
-		for _, tt := range cases {
-			t.Run(tt.Name, func(t *testing.T) {
-				err := parseError(tt.Resp)
-				for _, wantedError := range tt.WantedErrors {
-					assert.True(t, errors.Is(err, wantedError))
-				}
-			})
-		}
-	})
-}