From 5a515adab28ac292de96be5082ac3acda580264a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 4 Sep 2026 23:10:41 -0700 Subject: [PATCH] s3: HeadObject with partNumber returns the part's size and 206 (#11166) * s3: HEAD with partNumber reports the part's size and range HeadObject set its headers from the total object size and then only validated the partNumber, so a client probing part 1 with HEAD got the whole object's Content-Length and a 200 while the same GET returned the part's size, a Content-Range and a 206. Resolve the part's byte range before the headers are written, through the range logic GetObject already used, and answer a partNumber HEAD as the ranged HEAD that AWS documents. * s3: answer an unsatisfiable partNumber with 416 InvalidPartNumber GET and HEAD rejected a partNumber past the number of parts with 400 InvalidPart, the code for a missing part in CompleteMultipartUpload. AWS answers a read of a part that does not exist with 416 InvalidPartNumber, which lets a client probing for the part count tell the two apart. The ceph suite pins RGW's 400 InvalidPart here, so the s3tests jobs patch that expectation the way they already patch prefix ordering. * s3: keep the whole-object checksum off a partNumber response The stored checksum covers the whole object, so it is already withheld from a ranged read. A partNumber HEAD now describes one part while the request carries no Range header, so exclude it there too rather than handing a client a checksum that does not match the bytes described. * s3: resolve a partNumber against the parts the object records Completion accepts ascending, not consecutive, part numbers, so the part count is not the highest part number. Comparing the two rejected an uploaded part 3 of a two-part object, and let a request for the absent part 2 fall through to the positional chunk lookup and serve part 3's bytes. Ask the recorded boundaries for the part instead, and keep the count comparison for objects written before boundaries were stored. * s3: apply a client Range within the part on HEAD too GET narrowed the part by a Range sent alongside partNumber; HEAD reported the whole part, so the two disagreed again for a request that carries both. Move the narrowing into the shared range lookup so either verb describes the same bytes. --- .github/workflows/s3tests.yml | 6 + weed/s3api/s3api_object_handlers.go | 215 ++++++++++---------- weed/s3api/s3api_object_part_number_test.go | 142 +++++++++++++ weed/s3api/s3err/s3api_errors.go | 6 + 4 files changed, 257 insertions(+), 112 deletions(-) create mode 100644 weed/s3api/s3api_object_part_number_test.go diff --git a/.github/workflows/s3tests.yml b/.github/workflows/s3tests.yml index 6ac67ab36..980b921ff 100644 --- a/.github/workflows/s3tests.yml +++ b/.github/workflows/s3tests.yml @@ -128,6 +128,9 @@ jobs: echo "All SeaweedFS components are ready!" cd ../s3-tests sed -i "s/assert prefixes == \['foo%2B1\/', 'foo\/', 'quux%20ab\/'\]/assert prefixes == \['foo\/', 'foo%2B1\/', 'quux%20ab\/'\]/" s3tests/functional/test_s3.py + # The suite expects RGW's 400 InvalidPart for a partNumber past the last + # part; AWS answers 416 InvalidPartNumber, which is what we return. + sed -i "/# request PartNumber out of range/,+4{s/assert status == 400/assert status == 416/; s/assert error_code == 'InvalidPart'/assert error_code == 'InvalidPartNumber'/}" s3tests/functional/test_s3.py # Debug: Show the config file contents echo "=== S3 Config File Contents ===" @@ -968,6 +971,9 @@ jobs: echo "All SeaweedFS components are ready!" cd ../s3-tests sed -i "s/assert prefixes == \['foo%2B1\/', 'foo\/', 'quux%20ab\/'\]/assert prefixes == \['foo\/', 'foo%2B1\/', 'quux%20ab\/'\]/" s3tests/functional/test_s3.py + # The suite expects RGW's 400 InvalidPart for a partNumber past the last + # part; AWS answers 416 InvalidPartNumber, which is what we return. + sed -i "/# request PartNumber out of range/,+4{s/assert status == 400/assert status == 416/; s/assert error_code == 'InvalidPart'/assert error_code == 'InvalidPartNumber'/}" s3tests/functional/test_s3.py # Create and update s3tests.conf to use port 8004 cp ../docker/compose/s3tests.conf ../docker/compose/s3tests-sql.conf sed -i 's/port = 8000/port = 8004/g' ../docker/compose/s3tests-sql.conf diff --git a/weed/s3api/s3api_object_handlers.go b/weed/s3api/s3api_object_handlers.go index 902d983a8..f4563681d 100644 --- a/weed/s3api/s3api_object_handlers.go +++ b/weed/s3api/s3api_object_handlers.go @@ -850,75 +850,15 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request) // Continue with streaming immediately - will serve from remote or cached chunks } - // Check if PartNumber query parameter is present (for multipart GET requests) - partNumberStr := r.URL.Query().Get("partNumber") - if partNumberStr == "" { - partNumberStr = r.URL.Query().Get("PartNumber") - } - - // If PartNumber is specified, set headers and modify Range to read only that part - // This replicates the filer handler logic - if partNumberStr != "" { - if partNumber, parseErr := strconv.Atoi(partNumberStr); parseErr == nil && partNumber > 0 { - // Get actual parts count from metadata (not chunk count) - partsCount, partInfo := s3a.getMultipartInfo(objectEntryForSSE, partNumber) - - // Validate part number - if partNumber > partsCount { - glog.Warningf("GetObject: Invalid part number %d, object has %d parts", partNumber, partsCount) - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart) - return - } - - // Set parts count header - w.Header().Set(s3_constants.AmzMpPartsCount, strconv.Itoa(partsCount)) - glog.V(3).Infof("GetObject: Set PartsCount=%d for multipart GET with PartNumber=%d", partsCount, partNumber) - - // Calculate the byte range for this part - // Note: ETag is NOT overridden - AWS S3 returns the complete object's ETag - // even when requesting a specific part via PartNumber - var startOffset, endOffset int64 - if partInfo != nil { - var ok bool - startOffset, endOffset, ok = partRange(partInfo, objectEntryForSSE.Chunks) - if !ok { - glog.Errorf("GetObject: part %d boundary chunks [%d,%d) out of range (chunks: %d)", partNumber, partInfo.StartChunk, partInfo.EndChunk, len(objectEntryForSSE.Chunks)) - s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) - return - } - } else { - // Fallback: assume 1:1 part-to-chunk mapping (backward compatibility) - chunkIndex := partNumber - 1 - if chunkIndex >= len(objectEntryForSSE.Chunks) { - glog.Warningf("GetObject: Part %d chunk index %d out of range (chunks: %d)", partNumber, chunkIndex, len(objectEntryForSSE.Chunks)) - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart) - return - } - partChunk := objectEntryForSSE.Chunks[chunkIndex] - startOffset = partChunk.Offset - endOffset = partChunk.Offset + int64(partChunk.Size) - 1 - } - - // Check if client supplied a Range header - if so, apply it within the part's boundaries - // S3 allows both partNumber and Range together, where Range applies within the selected part - clientRangeHeader := r.Header.Get("Range") - if clientRangeHeader != "" { - adjustedStart, adjustedEnd, rangeErr := adjustRangeForPart(startOffset, endOffset, clientRangeHeader) - if rangeErr != nil { - glog.Warningf("GetObject: Invalid Range for part %d: %v", partNumber, rangeErr) - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) - return - } - startOffset = adjustedStart - endOffset = adjustedEnd - glog.V(3).Infof("GetObject: Client Range %s applied to part %d, adjusted to bytes=%d-%d", clientRangeHeader, partNumber, startOffset, endOffset) - } - - // Set Range header to read the requested bytes (full part or client-specified range within part) - rangeHeader := fmt.Sprintf("bytes=%d-%d", startOffset, endOffset) - r.Header.Set("Range", rangeHeader) - glog.V(3).Infof("GetObject: Set Range header for part %d: %s", partNumber, rangeHeader) + // If PartNumber is specified, turn the request into a ranged read of that part + if partNumber := requestedPartNumber(r); partNumber > 0 { + startOffset, endOffset, errCode := s3a.partByteRange(w, r, objectEntryForSSE, partNumber) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return } + r.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", startOffset, endOffset)) + glog.V(3).Infof("GetObject: part %d served as bytes=%d-%d", partNumber, startOffset, endOffset) } // NEW OPTIMIZATION: Stream directly from volume servers, bypassing filer proxy @@ -2289,10 +2229,10 @@ func (s3a *S3ApiServer) setResponseHeaders(w http.ResponseWriter, r *http.Reques // Set checksum header if stored in metadata, but only when: // 1. The request contains "x-amz-checksum-mode: ENABLED" (per AWS S3 spec) - // 2. The request is NOT a ranged GET (Range header absent) + // 2. The response covers the full object (no Range header, no partNumber) // The stored checksum covers the full object; returning it for partial // responses causes SDK checksum validation failures. - if r != nil && r.Header.Get("X-Amz-Checksum-Mode") == "ENABLED" && r.Header.Get("Range") == "" { + if r != nil && r.Header.Get("X-Amz-Checksum-Mode") == "ENABLED" && r.Header.Get("Range") == "" && requestedPartNumber(r) == 0 { if entry.Extended != nil { if algoName, ok := entry.Extended[s3_constants.ExtChecksumAlgorithm]; ok { if checksumVal, ok := entry.Extended[s3_constants.ExtChecksumValue]; ok { @@ -2596,36 +2536,24 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request // For HEAD requests, we already have all metadata - just set headers directly totalSize := int64(filer.FileSize(objectEntryForSSE)) - s3a.setResponseHeaders(w, r, objectEntryForSSE, totalSize) + responseSize := totalSize + statusCode := http.StatusOK - // Check if PartNumber query parameter is present (for multipart objects) - // This logic matches the filer handler for consistency - partNumberStr := r.URL.Query().Get("partNumber") - if partNumberStr == "" { - partNumberStr = r.URL.Query().Get("PartNumber") - } - - // If PartNumber is specified, set headers (matching filer logic) - if partNumberStr != "" { - if partNumber, parseErr := strconv.Atoi(partNumberStr); parseErr == nil && partNumber > 0 { - // Get actual parts count from metadata (not chunk count) - partsCount, _ := s3a.getMultipartInfo(objectEntryForSSE, partNumber) - - // Validate part number - if partNumber > partsCount { - glog.Warningf("HeadObject: Invalid part number %d, object has %d parts", partNumber, partsCount) - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart) - return - } - - // Set parts count header - // Note: ETag is NOT overridden - AWS S3 returns the complete object's ETag - // even when requesting a specific part via PartNumber - w.Header().Set(s3_constants.AmzMpPartsCount, strconv.Itoa(partsCount)) - glog.V(3).Infof("HeadObject: Set PartsCount=%d for part %d", partsCount, partNumber) + // A partNumber HEAD is a ranged HEAD of that part: report the part's size and range + if partNumber := requestedPartNumber(r); partNumber > 0 { + startOffset, endOffset, errCode := s3a.partByteRange(w, r, objectEntryForSSE, partNumber) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return } + responseSize = endOffset - startOffset + 1 + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startOffset, endOffset, totalSize)) + statusCode = http.StatusPartialContent + glog.V(3).Infof("HeadObject: part %d is bytes %d-%d/%d", partNumber, startOffset, endOffset, totalSize) } + s3a.setResponseHeaders(w, r, objectEntryForSSE, responseSize) + // Detect and handle SSE glog.V(3).Infof("HeadObjectHandler: Retrieved entry for %s/%s - %d chunks", bucket, object, len(objectEntryForSSE.Chunks)) sseType := s3a.detectPrimarySSEType(objectEntryForSSE) @@ -2656,7 +2584,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request s3a.addSSEResponseHeadersFromEntry(w, r, objectEntryForSSE, sseType) } - w.WriteHeader(http.StatusOK) + w.WriteHeader(statusCode) } // fetchObjectEntry fetches the filer entry for an object @@ -3254,16 +3182,17 @@ type rc struct { } // getMultipartInfo retrieves multipart metadata for a given part number -// Returns: (partsCount, partInfo) +// Returns: (partsCount, partInfo, hasBoundaries) // - partsCount: total number of parts in the multipart object // - partInfo: boundary information for the requested part (nil if not found or not a multipart object) -func (s3a *S3ApiServer) getMultipartInfo(entry *filer_pb.Entry, partNumber int) (int, *PartBoundaryInfo) { +// - hasBoundaries: the entry carries part boundaries, so a nil partInfo means the object has no such part +func (s3a *S3ApiServer) getMultipartInfo(entry *filer_pb.Entry, partNumber int) (int, *PartBoundaryInfo, bool) { if entry == nil { - return 0, nil + return 0, nil, false } if entry.Extended == nil { // Not a multipart object or no metadata - return len(entry.GetChunks()), nil + return len(entry.GetChunks()), nil, false } // Try to get parts count from metadata @@ -3275,20 +3204,82 @@ func (s3a *S3ApiServer) getMultipartInfo(entry *filer_pb.Entry, partNumber int) } // Try to get part boundaries from metadata - if boundariesJSON, exists := entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries]; exists { - var boundaries []PartBoundaryInfo - if err := json.Unmarshal(boundariesJSON, &boundaries); err == nil { - // Find the requested part - for i := range boundaries { - if boundaries[i].PartNumber == partNumber { - return partsCount, &boundaries[i] - } - } + boundariesJSON, exists := entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries] + if !exists { + return partsCount, nil, false + } + var boundaries []PartBoundaryInfo + if err := json.Unmarshal(boundariesJSON, &boundaries); err != nil { + glog.Warningf("getMultipartInfo: failed to unmarshal part boundaries: %v", err) + return partsCount, nil, false + } + for i := range boundaries { + if boundaries[i].PartNumber == partNumber { + return partsCount, &boundaries[i], true } } - // No part boundaries metadata or part not found - return partsCount, nil + // The object records its parts and this is not one of them + return partsCount, nil, true +} + +// requestedPartNumber returns the partNumber query parameter, or 0 when it is +// absent or not a positive integer. +func requestedPartNumber(r *http.Request) int { + partNumberStr := r.URL.Query().Get("partNumber") + if partNumberStr == "" { + partNumberStr = r.URL.Query().Get("PartNumber") + } + partNumber, parseErr := strconv.Atoi(partNumberStr) + if parseErr != nil || partNumber <= 0 { + return 0 + } + return partNumber +} + +// partByteRange resolves the inclusive byte range a partNumber request reads, +// narrowed by a client Range within the part, and sets the parts count header. +// The object ETag is kept as is, matching AWS. +func (s3a *S3ApiServer) partByteRange(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, partNumber int) (startOffset, endOffset int64, errCode s3err.ErrorCode) { + // Part numbers need not be consecutive, so an object that records its part + // boundaries is asked for the part itself rather than for a count. + partsCount, partInfo, hasBoundaries := s3a.getMultipartInfo(entry, partNumber) + if partInfo == nil && (hasBoundaries || partNumber > partsCount) { + glog.Warningf("partByteRange: object has no part %d, it has %d parts", partNumber, partsCount) + return 0, 0, s3err.ErrInvalidPartNumber + } + + w.Header().Set(s3_constants.AmzMpPartsCount, strconv.Itoa(partsCount)) + + if partInfo != nil { + var ok bool + startOffset, endOffset, ok = partRange(partInfo, entry.Chunks) + if !ok { + glog.Errorf("partByteRange: part %d boundary chunks [%d,%d) out of range (chunks: %d)", partNumber, partInfo.StartChunk, partInfo.EndChunk, len(entry.Chunks)) + return 0, 0, s3err.ErrInternalError + } + } else { + // Fallback: assume 1:1 part-to-chunk mapping (backward compatibility) + chunkIndex := partNumber - 1 + if chunkIndex >= len(entry.Chunks) { + glog.Warningf("partByteRange: part %d chunk index %d out of range (chunks: %d)", partNumber, chunkIndex, len(entry.Chunks)) + return 0, 0, s3err.ErrInvalidPartNumber + } + partChunk := entry.Chunks[chunkIndex] + startOffset, endOffset = partChunk.Offset, partChunk.Offset+int64(partChunk.Size)-1 + } + + // S3 allows both partNumber and Range together, where Range applies within the selected part + if clientRangeHeader := r.Header.Get("Range"); clientRangeHeader != "" { + adjustedStart, adjustedEnd, rangeErr := adjustRangeForPart(startOffset, endOffset, clientRangeHeader) + if rangeErr != nil { + glog.Warningf("partByteRange: invalid Range for part %d: %v", partNumber, rangeErr) + return 0, 0, s3err.ErrInvalidRange + } + startOffset, endOffset = adjustedStart, adjustedEnd + } + + return startOffset, endOffset, s3err.ErrNone } // buildRemoteObjectPath builds the filer directory and object name from S3 bucket/object. diff --git a/weed/s3api/s3api_object_part_number_test.go b/weed/s3api/s3api_object_part_number_test.go new file mode 100644 index 000000000..1e125cb6c --- /dev/null +++ b/weed/s3api/s3api_object_part_number_test.go @@ -0,0 +1,142 @@ +package s3api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + part1Size = 5 * 1024 * 1024 + part2Size = 3 * 1024 * 1024 +) + +func TestRequestedPartNumber(t *testing.T) { + for query, want := range map[string]int{ + "": 0, + "partNumber=1": 1, + "PartNumber=2": 2, + "partNumber=0": 0, + "partNumber=-1": 0, + "partNumber=abc": 0, + "versionId=v1": 0, + "partNumber=2&x=1": 2, + } { + r := httptest.NewRequest(http.MethodHead, "/bucket/object?"+query, nil) + assert.Equal(t, want, requestedPartNumber(r), "query %q", query) + } +} + +// twoPartEntry models a 2-part multipart object of part1Size + part2Size bytes. +func twoPartEntry(t *testing.T, withBoundaries bool) *filer_pb.Entry { + t.Helper() + entry := &filer_pb.Entry{ + Attributes: &filer_pb.FuseAttributes{FileSize: part1Size + part2Size}, + Chunks: []*filer_pb.FileChunk{ + {FileId: "1,a", Offset: 0, Size: part1Size}, + {FileId: "1,b", Offset: part1Size, Size: part2Size}, + }, + Extended: map[string][]byte{ + s3_constants.SeaweedFSMultipartPartsCount: []byte("2"), + }, + } + if withBoundaries { + boundaries, err := json.Marshal([]PartBoundaryInfo{ + {PartNumber: 1, StartChunk: 0, EndChunk: 1, StartOffset: 0, EndOffset: part1Size}, + {PartNumber: 2, StartChunk: 1, EndChunk: 2, StartOffset: part1Size, EndOffset: part1Size + part2Size}, + }) + require.NoError(t, err) + entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries] = boundaries + } + return entry +} + +// partRequest is a HEAD of partNumber, optionally narrowed by a client Range. +func partRequest(rangeHeader string) *http.Request { + r := httptest.NewRequest(http.MethodHead, "/bucket/object", nil) + if rangeHeader != "" { + r.Header.Set("Range", rangeHeader) + } + return r +} + +func TestPartByteRange(t *testing.T) { + s3a := &S3ApiServer{} + + for _, withBoundaries := range []bool{true, false} { + entry := twoPartEntry(t, withBoundaries) + + w := httptest.NewRecorder() + start, end, errCode := s3a.partByteRange(w, partRequest(""), entry, 1) + assert.Equal(t, s3err.ErrNone, errCode) + assert.Equal(t, int64(0), start) + assert.Equal(t, int64(part1Size-1), end) + assert.Equal(t, "2", w.Header().Get(s3_constants.AmzMpPartsCount)) + + w = httptest.NewRecorder() + start, end, errCode = s3a.partByteRange(w, partRequest(""), entry, 2) + assert.Equal(t, s3err.ErrNone, errCode) + assert.Equal(t, int64(part1Size), start) + assert.Equal(t, int64(part1Size+part2Size-1), end) + + // A client Range applies within the selected part + w = httptest.NewRecorder() + start, end, errCode = s3a.partByteRange(w, partRequest("bytes=0-1023"), entry, 2) + assert.Equal(t, s3err.ErrNone, errCode) + assert.Equal(t, int64(part1Size), start) + assert.Equal(t, int64(part1Size+1023), end) + + w = httptest.NewRecorder() + _, _, errCode = s3a.partByteRange(w, partRequest(""), entry, 3) + assert.Equal(t, s3err.ErrInvalidPartNumber, errCode, "part beyond the object must not resolve") + } + + // AWS answers an unsatisfiable partNumber with 416, not 400. + assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, s3err.GetAPIError(s3err.ErrInvalidPartNumber).HTTPStatusCode) +} + +// Completion accepts ascending, not consecutive, part numbers. +func TestPartByteRangeSparsePartNumbers(t *testing.T) { + s3a := &S3ApiServer{} + entry := twoPartEntry(t, true) + boundaries, err := json.Marshal([]PartBoundaryInfo{ + {PartNumber: 1, StartChunk: 0, EndChunk: 1, StartOffset: 0, EndOffset: part1Size}, + {PartNumber: 3, StartChunk: 1, EndChunk: 2, StartOffset: part1Size, EndOffset: part1Size + part2Size}, + }) + require.NoError(t, err) + entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries] = boundaries + + start, end, errCode := s3a.partByteRange(httptest.NewRecorder(), partRequest(""), entry, 3) + assert.Equal(t, s3err.ErrNone, errCode, "the uploaded part 3 must resolve") + assert.Equal(t, int64(part1Size), start) + assert.Equal(t, int64(part1Size+part2Size-1), end) + + _, _, errCode = s3a.partByteRange(httptest.NewRecorder(), partRequest(""), entry, 2) + assert.Equal(t, s3err.ErrInvalidPartNumber, errCode, "part 2 was never uploaded") +} + +// The stored checksum covers the whole object, so a part response must not carry it. +func TestSetResponseHeadersSkipsChecksumForPart(t *testing.T) { + s3a := &S3ApiServer{} + entry := twoPartEntry(t, true) + entry.Extended[s3_constants.ExtChecksumAlgorithm] = []byte("x-amz-checksum-crc32") + entry.Extended[s3_constants.ExtChecksumValue] = []byte("AAAAAA==") + + for query, want := range map[string]string{ + "": "AAAAAA==", + "partNumber=1": "", + } { + r := httptest.NewRequest(http.MethodHead, "/bucket/object?"+query, nil) + r.Header.Set("X-Amz-Checksum-Mode", "ENABLED") + w := httptest.NewRecorder() + s3a.setResponseHeaders(w, r, entry, part1Size) + assert.Equal(t, want, w.Header().Get("x-amz-checksum-crc32"), "query %q", query) + } +} diff --git a/weed/s3api/s3err/s3api_errors.go b/weed/s3api/s3err/s3api_errors.go index 0d5afa13f..9fdbb1ea1 100644 --- a/weed/s3api/s3err/s3api_errors.go +++ b/weed/s3api/s3err/s3api_errors.go @@ -73,6 +73,7 @@ const ( ErrInvalidMaxDeleteObjects ErrInvalidPartNumberMarker ErrInvalidPart + ErrInvalidPartNumber ErrInvalidPartOrder ErrInvalidRange ErrInternalError @@ -349,6 +350,11 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", HTTPStatusCode: http.StatusBadRequest, }, + ErrInvalidPartNumber: { + Code: "InvalidPartNumber", + Description: "The requested partnumber is not satisfiable.", + HTTPStatusCode: http.StatusRequestedRangeNotSatisfiable, + }, ErrInvalidPartOrder: { Code: "InvalidPartOrder", Description: "The list of parts was not in ascending order. The parts list must be specified in order by part number.",