Repository: opensearch-project/opensearch-go

Issue:
[BUG] Missing "caused by" information in StructError
### What is the bug?
OpenSearch reports "caused_by" nested information in error information contained in JSON response bodies. That information is missing when unmarshalling errors to correspondent types.

### How can one reproduce the bug?
Requesting to PUT via the `_index_template/posts` endpoint an invalid template payload (with an invalid filter for instance) will get a response JSON error including some `caused_by` information. In this particular scenario response body will be similar to:

```json
{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"composable template [posts] template after composition is invalid"}],"type":"illegal_argument_exception","reason":"composable template [posts] template after composition is invalid","caused_by":{"type":"illegal_argument_exception","reason":"Custom Analyzer [mm_hashtag] failed to find filter under name [invalid-filter]"}}
```

### What is the expected behavior?
Caused by information shouldn't be missing in the error type returned by the library.


Discussion:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b708de..170c5c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
 ### Removed
 
 ### Fixed
+- Missing "caused by" information in StructError ([#752](https://github.com/opensearch-project/opensearch-go/pull/752))
 
 ### Security
 
diff --git a/error.go b/error.go
index 92016c4..6259884 100644
--- a/error.go
+++ b/error.go
@@ -75,10 +75,26 @@ type Err struct {
 	RootCause []RootCause `json:"root_cause"`
 	Type      string      `json:"type"`
 	Reason    string      `json:"reason"`
+	CausedBy  *CausedBy   `json:"caused_by,omitempty"`
 	Index     string      `json:"index,omitempty"`
 	IndexUUID string      `json:"index_uuid,omitempty"`
 }
 
+// CausedBy represents the optional caused_by of an API error response. Causes can be nested
+type CausedBy struct {
+	Type     string    `json:"type"`
+	Reason   string    `json:"reason"`
+	CausedBy *CausedBy `json:"caused_by,omitempty"`
+}
+
+// String returns a string representation of CausedBy, handling nested structures
+func (c *CausedBy) String() string {
+	if c.CausedBy == nil {
+		return fmt.Sprintf("{type: %s, reason: %s}", c.Type, c.Reason)
+	}
+	return fmt.Sprintf("{type: %s, reason: %s, caused_by: %s}", c.Type, c.Reason, c.CausedBy)
+}
+
 // RootCause represents the root_cause of an API error response
 type RootCause struct {
 	Type      string `json:"type"`
@@ -89,7 +105,13 @@ type RootCause struct {
 
 // 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)
+	result := fmt.Sprintf("status: %d, type: %s, reason: %s, root_cause: %s", e.Status, e.Err.Type, e.Err.Reason, e.Err.RootCause)
+
+	if e.Err.CausedBy != nil {
+		result += fmt.Sprintf(", caused_by: %s", e.Err.CausedBy)
+	}
+
+	return result
 }
 
 // UnmarshalJSON is a custom unmarshal function for StructError returning custom errors in special cases



Test patch:
diff --git a/error_test.go b/error_test.go
index 2a176c0..51fbcdc 100644
--- a/error_test.go
+++ b/error_test.go
@@ -64,6 +64,51 @@ func TestError(t *testing.T) {
 			_ = fmt.Sprintf("%s", err)
 		})
 
+		t.Run("CausedBy", func(t *testing.T) {
+			resp := &opensearch.Response{
+				StatusCode: http.StatusBadRequest,
+				Body: io.NopCloser(
+					strings.NewReader(`{
+						"error":{
+							"root_cause":[{
+								"type":"illegal_argument_exception",
+								"reason":"composable template [posts] template after composition is invalid"
+							}],
+							"type":"illegal_argument_exception",
+							"reason":"composable template [posts] template after composition is invalid",
+							"caused_by":{
+								"type":"illegal_argument_exception",
+								"reason":"Custom analyzer [mm_analyzer] failed to find filter under name [test_filter]",
+								"caused_by":{
+									"type":"illegal_argument_exception",
+									"reason":"test caused by"
+								}
+							}
+						},
+						"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, "illegal_argument_exception", testError.Err.Type)
+			assert.Equal(t, "composable template [posts] template after composition is invalid", testError.Err.Reason)
+			assert.NotNil(t, testError.Err.RootCause)
+			assert.Equal(t, "illegal_argument_exception", testError.Err.RootCause[0].Type)
+			assert.Equal(t, "composable template [posts] template after composition is invalid", testError.Err.RootCause[0].Reason)
+			assert.NotNil(t, testError.Err.CausedBy)
+			assert.Equal(t, "illegal_argument_exception", testError.Err.CausedBy.Type)
+			assert.Equal(t, "Custom analyzer [mm_analyzer] failed to find filter under name [test_filter]", testError.Err.CausedBy.Reason)
+			assert.NotNil(t, testError.Err.CausedBy.CausedBy)
+			assert.Equal(t, "illegal_argument_exception", testError.Err.CausedBy.CausedBy.Type)
+			assert.Equal(t, "test caused by", testError.Err.CausedBy.CausedBy.Reason)
+			assert.Nil(t, testError.Err.CausedBy.CausedBy.CausedBy)
+			_ = fmt.Sprintf("%s", err)
+		})
+
 		t.Run("Unmarshal errors", func(t *testing.T) {
 			t.Run("dummy", func(t *testing.T) {
 				reader := io.NopCloser(