Files
seaweedfs/weed/pb/filer_pb/filer_pb_helper_test.go
T
Eliah RusinandClaude Opus 5.5 164c3db606 s3: return 403, not 500, when an over-quota bucket refuses a write (#11552)
* s3: return 403, not 500, when an over-quota bucket refuses a write

Filer AssignVolume flattened ErrReadOnly into the free-text
AssignVolumeResponse.Error string, so S3 PutObject / PutObjectPart via
UploadReaderInChunks could not match it with errors.Is and fell through
to 500 InternalError: retryable, and it hides the quota.

Add FilerError READ_ONLY and AssignVolumeResponse.error_code, set it
alongside the unchanged error text, and rebuild the sentinel with
filer_pb.AssignVolumeResponseError. weed_server.ErrReadOnly now aliases
filer_pb.ErrReadOnly so errors.Is matches on both sides, and
mapChunkedUploadErrorToS3Error maps it to ErrAccessDenied. There is no
"read only" substring matching, so a volume server's "volume N is read
only" stays retryable.

Carrying the verdict as a response code rather than a gRPC status keeps
clients from treating it as a transport failure: the S3 gateway does not
fail over across filers and the Java client does not retry it.

Wrap per-chunk copy errors with %w so CopyObject keeps the sentinel, and
map UploadPartCopy chunk errors through mapCopyErrorToS3Error instead of
always returning 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci: re-run integration tests (PyPI download timeout)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:21:03 +08:00

87 lines
3.5 KiB
Go

package filer_pb
import (
"errors"
"os"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
func TestAssignVolumeResponseError(t *testing.T) {
if err := AssignVolumeResponseError(&AssignVolumeResponse{FileId: "3,01"}); err != nil {
t.Fatalf("success response: err = %v, want nil", err)
}
// A filer that predates error_code: the text survives, no sentinel is implied.
err := AssignVolumeResponseError(&AssignVolumeResponse{Error: "assign volume: no free volumes"})
if err == nil || err.Error() != "assign volume: no free volumes" {
t.Fatalf("uncoded response: err = %v, want the filer's text", err)
}
if errors.Is(err, ErrReadOnly) {
t.Fatal("uncoded response must not match ErrReadOnly")
}
msg := "assign volume: read only: /buckets/b (e.g. bucket over quota)"
err = AssignVolumeResponseError(&AssignVolumeResponse{Error: msg, ErrorCode: FilerError_READ_ONLY})
if !errors.Is(err, ErrReadOnly) {
t.Fatalf("READ_ONLY response: err = %v, want ErrReadOnly", err)
}
if err.Error() != msg {
t.Fatalf("READ_ONLY response: message = %q, want %q", err.Error(), msg)
}
}
func TestIsDirectoryKeyObject(t *testing.T) {
chunk := []*FileChunk{{FileId: "1,01", Size: 75}}
cases := []struct {
name string
e *Entry
want bool
}{
{"plain directory", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}}, false},
{"directory marker with mime", &Entry{IsDirectory: true, Attributes: &FuseAttributes{Mime: "application/octet-stream"}}, true},
{"directory promoted from file keeps chunks", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, Chunks: chunk}, true},
{"directory promoted from small file keeps content", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, Content: []byte("abc")}, true},
{"directory promoted from remote-tiered file", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, RemoteEntry: &RemoteEntry{RemoteSize: 100}}, true},
{"directory with chunks and nil attributes", &Entry{IsDirectory: true, Chunks: chunk}, true},
{"regular file with chunks", &Entry{IsDirectory: false, Attributes: &FuseAttributes{}, Chunks: chunk}, false},
{"remote mount directory has no remote size", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, RemoteEntry: &RemoteEntry{StorageName: "s3"}}, false},
{"empty prefix object has only the mark", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, Extended: map[string][]byte{s3_constants.SeaweedFSPrefixObject: []byte("true")}}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.e.IsDirectoryKeyObject(); got != c.want {
t.Errorf("IsDirectoryKeyObject() = %v, want %v", got, c.want)
}
})
}
}
func TestMarkPrefixObject(t *testing.T) {
entry := &Entry{Name: "foo", Attributes: &FuseAttributes{FileMode: 0644}}
if entry.IsPrefixObject() {
t.Fatal("a file is not a prefix object")
}
entry.MarkPrefixObject()
if !entry.IsDirectory || !entry.IsPrefixObject() {
t.Errorf("MarkPrefixObject() left %+v", entry)
}
// The filer derives the entry type from the mode, so the directory bit has to be
// on it for the store to keep this as a directory.
if mode := os.FileMode(entry.Attributes.FileMode); mode&os.ModeDir == 0 || mode.Perm()&0111 != 0111 {
t.Errorf("FileMode = %v, want a traversable directory", mode)
}
// A directory the mark was stripped from is a plain directory again.
delete(entry.Extended, s3_constants.SeaweedFSPrefixObject)
if entry.IsPrefixObject() || entry.IsDirectoryKeyObject() {
t.Error("a demoted directory names no key")
}
}