feat(apierr): parse Garage structured error responses into UpstreamError

This commit is contained in:
Noooste
2026-04-20 00:01:31 +02:00
parent 5c2e5dbf6f
commit f2cc660c8f
2 changed files with 132 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
package apierr
import (
"encoding/json"
"io"
"github.com/Noooste/azuretls-client"
)
// garageErrBody mirrors Garage's JSON error envelope.
type garageErrBody struct {
Code string `json:"code"`
Message string `json:"message"`
Region string `json:"region"`
Path string `json:"path"`
}
// ParseGarage returns nil for 2xx responses. For non-2xx it reads the body
// once, JSON-decodes the structured Garage error envelope, and returns a
// *UpstreamError. Malformed bodies are preserved verbatim in Message.
//
// ParseGarage DOES NOT close resp.RawBody on the success path — callers that
// decode the success body (decodeResponse in services/admin.go) still need
// access and are responsible for closing. On the error path the body is fully
// consumed before return.
func ParseGarage(resp *azuretls.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
bodyBytes, _ := io.ReadAll(resp.RawBody)
ue := &UpstreamError{
HTTPStatus: resp.StatusCode,
Source: "garage",
}
var parsed garageErrBody
if len(bodyBytes) > 0 && json.Unmarshal(bodyBytes, &parsed) == nil && parsed.Code != "" {
ue.Code = parsed.Code
ue.Message = parsed.Message
ue.Details = map[string]string{}
if parsed.Region != "" {
ue.Details["region"] = parsed.Region
}
if parsed.Path != "" {
ue.Details["path"] = parsed.Path
}
} else {
ue.Message = string(bodyBytes)
}
return ue
}
+79
View File
@@ -0,0 +1,79 @@
package apierr
import (
"bytes"
"io"
"strings"
"testing"
"github.com/Noooste/azuretls-client"
)
func fakeResp(status int, body string) *azuretls.Response {
return &azuretls.Response{
StatusCode: status,
RawBody: io.NopCloser(bytes.NewBufferString(body)),
}
}
func TestParseGarage_Success(t *testing.T) {
resp := fakeResp(200, `{"foo":"bar"}`)
if err := ParseGarage(resp); err != nil {
t.Fatalf("ParseGarage(2xx) returned %v, want nil", err)
}
}
func TestParseGarage_StructuredError(t *testing.T) {
body := `{"code":"BucketNotEmpty","message":"Tried to delete a non-empty bucket","region":"eu-west-1","path":"/v2/DeleteBucket"}`
resp := fakeResp(409, body)
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 409 {
t.Errorf("HTTPStatus = %d, want 409", ue.HTTPStatus)
}
if ue.Code != "BucketNotEmpty" {
t.Errorf("Code = %q, want BucketNotEmpty", ue.Code)
}
if ue.Message != "Tried to delete a non-empty bucket" {
t.Errorf("Message = %q", ue.Message)
}
if ue.Source != "garage" {
t.Errorf("Source = %q, want garage", ue.Source)
}
if ue.Details["region"] != "eu-west-1" || ue.Details["path"] != "/v2/DeleteBucket" {
t.Errorf("Details = %v", ue.Details)
}
}
func TestParseGarage_MalformedBody(t *testing.T) {
resp := fakeResp(500, "not json at all")
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 500 || ue.Code != "" {
t.Errorf("HTTPStatus=%d Code=%q, want 500 and empty code", ue.HTTPStatus, ue.Code)
}
if !strings.Contains(ue.Message, "not json at all") {
t.Errorf("Message = %q, expected raw body", ue.Message)
}
}
func TestParseGarage_EmptyBody(t *testing.T) {
resp := fakeResp(502, "")
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 502 {
t.Errorf("HTTPStatus = %d, want 502", ue.HTTPStatus)
}
}