From f2cc660c8f8ee31379b012994ee2a4109db4374c Mon Sep 17 00:00:00 2001 From: Noooste <83548733+Noooste@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:01:31 +0200 Subject: [PATCH] feat(apierr): parse Garage structured error responses into UpstreamError --- backend/internal/apierr/parse.go | 53 ++++++++++++++++++ backend/internal/apierr/parse_test.go | 79 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 backend/internal/apierr/parse.go create mode 100644 backend/internal/apierr/parse_test.go diff --git a/backend/internal/apierr/parse.go b/backend/internal/apierr/parse.go new file mode 100644 index 0000000..04aa7d6 --- /dev/null +++ b/backend/internal/apierr/parse.go @@ -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 +} diff --git a/backend/internal/apierr/parse_test.go b/backend/internal/apierr/parse_test.go new file mode 100644 index 0000000..c24f75a --- /dev/null +++ b/backend/internal/apierr/parse_test.go @@ -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) + } +}