Skip to content

Commit d1e1ecd

Browse files
authored
fix: return delete-marker ObjectInfo from StatObject alongside the error (#2273)
1 parent f230068 commit d1e1ecd

2 files changed

Lines changed: 260 additions & 22 deletions

File tree

‎api-stat.go‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,12 @@ func (c *Client) BucketExists(ctx context.Context, bucketName string) (bool, err
5757
}
5858

5959
// StatObject verifies if object exists, you have permission to access it
60-
// and returns information about the object.
60+
// and returns information about the object. When the returned error is
61+
// non-nil but a response was received, the ObjectInfo still carries the
62+
// VersionID and IsDeleteMarker values parsed from the response headers,
63+
// plus ReplicationReady on every error path except the versioned
64+
// delete-marker 405 (an asymmetry preserved from the pre-v7.0.93
65+
// behavior, which populated ReplicationReady on the generic branch only).
6166
func (c *Client) StatObject(ctx context.Context, bucketName, objectName string, opts StatObjectOptions) (ObjectInfo, error) {
6267
// Input validation.
6368
if err := s3utils.CheckValidBucketName(bucketName); err != nil {
@@ -92,32 +97,32 @@ func (c *Client) StatObject(ctx context.Context, bucketName, objectName string,
9297
})
9398
defer closeResponse(resp)
9499
if err != nil {
95-
return ObjectInfo{}, err
96-
}
97-
98-
if resp != nil {
100+
// executeMethod returns a non-nil error for every non-success
101+
// status. When a response exists, its headers still carry the
102+
// version and delete-marker fields — surface them with the error.
103+
if resp == nil {
104+
return ObjectInfo{}, err
105+
}
99106
deleteMarker := resp.Header.Get(amzDeleteMarker) == "true"
100107
replicationReady := resp.Header.Get(minioTgtReplicationReady) == "true"
101-
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
102-
if resp.StatusCode == http.StatusMethodNotAllowed && opts.VersionID != "" && deleteMarker {
103-
errResp := ErrorResponse{
104-
StatusCode: resp.StatusCode,
105-
Code: MethodNotAllowed,
106-
Message: s3ErrorResponseMap[MethodNotAllowed],
107-
BucketName: bucketName,
108-
Key: objectName,
109-
}
110-
return ObjectInfo{
111-
VersionID: resp.Header.Get(amzVersionID),
112-
IsDeleteMarker: deleteMarker,
113-
}, errResp
108+
if resp.StatusCode == http.StatusMethodNotAllowed && opts.VersionID != "" && deleteMarker {
109+
errResp := ErrorResponse{
110+
StatusCode: resp.StatusCode,
111+
Code: MethodNotAllowed,
112+
Message: s3ErrorResponseMap[MethodNotAllowed],
113+
BucketName: bucketName,
114+
Key: objectName,
114115
}
115116
return ObjectInfo{
116-
VersionID: resp.Header.Get(amzVersionID),
117-
IsDeleteMarker: deleteMarker,
118-
ReplicationReady: replicationReady, // whether delete marker can be replicated
119-
}, httpRespToErrorResponse(resp, bucketName, objectName)
117+
VersionID: resp.Header.Get(amzVersionID),
118+
IsDeleteMarker: deleteMarker,
119+
}, errResp
120120
}
121+
return ObjectInfo{
122+
VersionID: resp.Header.Get(amzVersionID),
123+
IsDeleteMarker: deleteMarker,
124+
ReplicationReady: replicationReady, // whether delete marker can be replicated
125+
}, err
121126
}
122127

123128
return ToObjectInfo(bucketName, objectName, resp.Header)

‎api-stat_test.go‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/*
2+
* MinIO Go Library for Amazon S3 Compatible Cloud Storage
3+
* Copyright 2026 MinIO, Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package minio
19+
20+
import (
21+
"context"
22+
"net/http"
23+
"net/http/httptest"
24+
"reflect"
25+
"testing"
26+
27+
"github.com/minio/minio-go/v7/pkg/credentials"
28+
)
29+
30+
// newTestStatClient returns a Client pointed at an httptest server that
31+
// serves handler; the server is closed via t.Cleanup.
32+
func newTestStatClient(t *testing.T, handler http.HandlerFunc) *Client {
33+
t.Helper()
34+
srv := httptest.NewServer(handler)
35+
t.Cleanup(srv.Close)
36+
37+
clnt, err := New(srv.Listener.Addr().String(), &Options{
38+
Creds: credentials.NewStaticV4("foo", "foo12345", ""),
39+
Region: "us-east-1",
40+
})
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
return clnt
45+
}
46+
47+
// Tests that StatObject returns the delete-marker ObjectInfo fields
48+
// (VersionID and IsDeleteMarker — ReplicationReady is deliberately not
49+
// merged into this return) and the MethodNotAllowed error code when a
50+
// versioned HEAD hits a delete marker (HTTP 405).
51+
func TestStatObjectDeleteMarker(t *testing.T) {
52+
clnt := newTestStatClient(t, func(w http.ResponseWriter, _ *http.Request) {
53+
w.Header().Set(amzDeleteMarker, "true")
54+
w.Header().Set(amzVersionID, "test-version-id")
55+
w.Header().Set(minioTgtReplicationReady, "true")
56+
w.WriteHeader(http.StatusMethodNotAllowed)
57+
})
58+
59+
objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name",
60+
StatObjectOptions{VersionID: "test-version-id"})
61+
if err == nil {
62+
t.Fatal("expected error for delete marker, got nil")
63+
}
64+
errResp := ToErrorResponse(err)
65+
if errResp.Code != MethodNotAllowed {
66+
t.Errorf("error code = %q, want %q", errResp.Code, MethodNotAllowed)
67+
}
68+
if errResp.StatusCode != http.StatusMethodNotAllowed {
69+
t.Errorf("error status = %d, want %d", errResp.StatusCode, http.StatusMethodNotAllowed)
70+
}
71+
if errResp.BucketName != "bucket-name" || errResp.Key != "object-name" {
72+
t.Errorf("error bucket/key = %q/%q, want %q/%q",
73+
errResp.BucketName, errResp.Key, "bucket-name", "object-name")
74+
}
75+
if !objInfo.IsDeleteMarker {
76+
t.Error("expected IsDeleteMarker to be true")
77+
}
78+
if objInfo.VersionID != "test-version-id" {
79+
t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id")
80+
}
81+
if objInfo.ReplicationReady {
82+
t.Error("expected ReplicationReady to stay false on the delete-marker return")
83+
}
84+
}
85+
86+
// Tests that a 405 response missing either half of the delete-marker
87+
// shape (the x-amz-delete-marker header, or a version-targeted stat)
88+
// falls through to the generic error path with the raw status code.
89+
func TestStatObjectMethodNotAllowedGeneric(t *testing.T) {
90+
const wantCode = "405 Method Not Allowed"
91+
tests := []struct {
92+
name string
93+
deleteMarker bool
94+
versionID string
95+
}{
96+
{"no delete-marker header", false, "test-version-id"},
97+
{"no version id", true, ""},
98+
}
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
clnt := newTestStatClient(t, func(w http.ResponseWriter, _ *http.Request) {
102+
if tt.deleteMarker {
103+
w.Header().Set(amzDeleteMarker, "true")
104+
}
105+
w.Header().Set(amzVersionID, "test-version-id")
106+
w.WriteHeader(http.StatusMethodNotAllowed)
107+
})
108+
109+
objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name",
110+
StatObjectOptions{VersionID: tt.versionID})
111+
if err == nil {
112+
t.Fatal("expected error, got nil")
113+
}
114+
if errResp := ToErrorResponse(err); errResp.Code != wantCode {
115+
t.Errorf("error code = %q, want %q", errResp.Code, wantCode)
116+
}
117+
if objInfo.IsDeleteMarker != tt.deleteMarker {
118+
t.Errorf("IsDeleteMarker = %v, want %v", objInfo.IsDeleteMarker, tt.deleteMarker)
119+
}
120+
if objInfo.VersionID != "test-version-id" {
121+
t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id")
122+
}
123+
})
124+
}
125+
}
126+
127+
// Tests that 202 and 204 responses, which executeMethod treats as
128+
// success, are parsed like a 200 instead of being converted into errors.
129+
func TestStatObjectNoContentSuccess(t *testing.T) {
130+
for _, status := range []int{http.StatusAccepted, http.StatusNoContent} {
131+
t.Run(http.StatusText(status), func(t *testing.T) {
132+
clnt := newTestStatClient(t, func(w http.ResponseWriter, _ *http.Request) {
133+
w.Header().Set("Last-Modified", "Thu, 30 Jul 2026 00:00:00 GMT")
134+
w.Header().Set("ETag", `"deadbeef"`)
135+
w.Header().Set(amzVersionID, "test-version-id")
136+
w.WriteHeader(status)
137+
})
138+
139+
objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{})
140+
if err != nil {
141+
t.Fatalf("expected nil error for %d, got %v", status, err)
142+
}
143+
if objInfo.ETag != "deadbeef" {
144+
t.Errorf("ETag = %q, want %q", objInfo.ETag, "deadbeef")
145+
}
146+
if objInfo.VersionID != "test-version-id" {
147+
t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id")
148+
}
149+
})
150+
}
151+
}
152+
153+
// Tests that StatObject returns a zero ObjectInfo when the request fails
154+
// before any response is received.
155+
func TestStatObjectNoResponse(t *testing.T) {
156+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
157+
w.WriteHeader(http.StatusOK)
158+
}))
159+
addr := srv.Listener.Addr().String()
160+
srv.Close()
161+
162+
clnt, err := New(addr, &Options{
163+
Creds: credentials.NewStaticV4("foo", "foo12345", ""),
164+
Region: "us-east-1",
165+
MaxRetries: 1,
166+
})
167+
if err != nil {
168+
t.Fatal(err)
169+
}
170+
171+
objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name", StatObjectOptions{})
172+
if err == nil {
173+
t.Fatal("expected error for unreachable endpoint, got nil")
174+
}
175+
if !reflect.DeepEqual(objInfo, ObjectInfo{}) {
176+
t.Errorf("expected zero ObjectInfo, got %+v", objInfo)
177+
}
178+
}
179+
180+
// Tests that StatObject surfaces the delete-marker and replication-ready
181+
// headers on a generic error response, e.g. HEAD on an object whose
182+
// latest version is a delete marker (HTTP 404) — and that the
183+
// IsReplicationReadyForDeleteMarker option puts the matching check
184+
// header on the request.
185+
func TestStatObjectErrorHeaders(t *testing.T) {
186+
var gotReplicationReadyCheck string
187+
clnt := newTestStatClient(t, func(w http.ResponseWriter, r *http.Request) {
188+
gotReplicationReadyCheck = r.Header.Get(isMinioTgtReplicationReady)
189+
w.Header().Set(amzDeleteMarker, "true")
190+
w.Header().Set(amzVersionID, "test-version-id")
191+
w.Header().Set(minioTgtReplicationReady, "true")
192+
w.WriteHeader(http.StatusNotFound)
193+
})
194+
195+
objInfo, err := clnt.StatObject(context.Background(), "bucket-name", "object-name",
196+
StatObjectOptions{Internal: AdvancedGetOptions{IsReplicationReadyForDeleteMarker: true}})
197+
if err == nil {
198+
t.Fatal("expected error, got nil")
199+
}
200+
if errResp := ToErrorResponse(err); errResp.Code != NoSuchKey {
201+
t.Errorf("error code = %q, want %q", errResp.Code, NoSuchKey)
202+
}
203+
if !objInfo.IsDeleteMarker {
204+
t.Error("expected IsDeleteMarker to be true")
205+
}
206+
if objInfo.VersionID != "test-version-id" {
207+
t.Errorf("VersionID = %q, want %q", objInfo.VersionID, "test-version-id")
208+
}
209+
if !objInfo.ReplicationReady {
210+
t.Error("expected ReplicationReady to be true")
211+
}
212+
if gotReplicationReadyCheck != "true" {
213+
t.Errorf("request header %s = %q, want %q",
214+
isMinioTgtReplicationReady, gotReplicationReadyCheck, "true")
215+
}
216+
}
217+
218+
// Tests that the delete-marker branch is gated on the 405 status: a
219+
// non-405 error carrying the same delete-marker and version shape must
220+
// still take the generic path.
221+
func TestStatObjectDeleteMarkerNon405(t *testing.T) {
222+
clnt := newTestStatClient(t, func(w http.ResponseWriter, _ *http.Request) {
223+
w.Header().Set(amzDeleteMarker, "true")
224+
w.Header().Set(amzVersionID, "test-version-id")
225+
w.WriteHeader(http.StatusNotFound)
226+
})
227+
228+
_, err := clnt.StatObject(context.Background(), "bucket-name", "object-name",
229+
StatObjectOptions{VersionID: "test-version-id"})
230+
if errResp := ToErrorResponse(err); errResp.Code != NoSuchKey {
231+
t.Errorf("error code = %q, want %q", errResp.Code, NoSuchKey)
232+
}
233+
}

0 commit comments

Comments
 (0)