Skip to content
111 changes: 111 additions & 0 deletions pkg/azurestore/azureservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import (
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
Expand Down Expand Up @@ -68,6 +70,8 @@ type AzBlob interface {
Upload(ctx context.Context, body io.ReadSeeker) error
// Download returns a readcloser to download the contents of the blob
Download(ctx context.Context) (io.ReadCloser, error)
// Serves the contents of the blob directly handling special HTTP headers like Range, if set
ServeContent(ctx context.Context, w http.ResponseWriter, r *http.Request) error
// Get the offset of the blob and its indexes
GetOffset(ctx context.Context) (int64, error)
// Commit the uploaded blocks to the BlockBlob
Expand Down Expand Up @@ -199,6 +203,64 @@ func (blockBlob *BlockBlob) Download(ctx context.Context) (io.ReadCloser, error)
return resp.Body, nil
}

// Serve content respecting range header
func (blockBlob *BlockBlob) ServeContent(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
var downloadOptions, err = ParseDownloadOptions(r)
if err != nil {
return err
}
result, err := blockBlob.BlobClient.DownloadStream(ctx, downloadOptions)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've done some tests using this branch, so first I'd like to thank you for your work :).

The only issue we found is that when a client does a request with a range above the total content length he'll receive an 500 error response instead of 416 because of the error being returned here.

I think it would be better to return the 416 response here, including the content-range header (example value: bytes */64978)

	if err != nil {
		// copy header "Content-Range", "X-Ms-Error-Code", "Date", "X-Ms-Request-Id"; Body empty; StatusCode+Status if present in error response
		var azureError *azcore.ResponseError
		if errors.As(err, &azureError) {
			if azureError.StatusCode == http.StatusRequestedRangeNotSatisfiable {
				if azureError.RawResponse != nil {
					if val := azureError.RawResponse.Header.Get("Content-Range"); val != "" {
						w.Header().Set("Content-Range", val)
					}
					if val := azureError.RawResponse.Header.Get("X-Ms-Error-Code"); val != "" {
						w.Header().Set("X-Ms-Error-Code", val)
					}
					if val := azureError.RawResponse.Header.Get("X-Ms-Request-Id"); val != "" {
						w.Header().Set("X-Ms-Request-Id", val)
					}
					if val := azureError.RawResponse.Header.Get("Date"); val != "" {
						w.Header().Set("Date", val)
					}
				}
				w.WriteHeader(azureError.StatusCode)
				return nil
			}
		}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Also missing http.StatusPreconditionFailed in case of If-None-Match header is used.
  • Might want to include http.StatusNotFound, though that should normally not be hit since GetInfo in unroute_handler will fail before reaching ServerContent. File might no longer exist later on though.

So updated condition would be:

// ...
			if http.StatusRequestedRangeNotSatisfiable == azureError.StatusCode ||
				http.StatusPreconditionFailed == azureError.StatusCode ||
				http.StatusNotFound == azureError.StatusCode {
// ...

}
defer result.Body.Close()

statusCode := http.StatusOK
if result.ContentRange != nil {
// Use 206 Partial Content for range requests
statusCode = http.StatusPartialContent
} else if result.ContentLength != nil && *result.ContentLength == 0 {
statusCode = http.StatusNoContent
Copy link
Contributor

@quality-leftovers quality-leftovers Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When ETag is matched the response should be 304 StatusNotModified. The following allows doing so use the returned error header. Sadly the SDK does NOT expose the status code / actual typed error.

   } else if result.ContentLength != nil && *result.ContentLength == 0 {
		if result.ErrorCode != nil && *result.ErrorCode == string(bloberror.ConditionNotMet) &&
			downloadOptions != nil && downloadOptions.AccessConditions != nil &&
			downloadOptions.AccessConditions.ModifiedAccessConditions != nil && downloadOptions.AccessConditions.ModifiedAccessConditions.IfNoneMatch != nil {
			// If the client sent an If-None-Match header and we get an X-Ms-Error-Code "ConditionNotMet", return 304 Not Modified
			statusCode = http.StatusNotModified
		} else {
			statusCode = http.StatusNoContent
		}
   }

}

// Add Accept-Ranges,Content-*, Cache-Control, ETag, Expires, Last-Modified headers if present in azure response
if result.AcceptRanges != nil {
w.Header().Set("Accept-Ranges", *result.AcceptRanges)
}
if result.ContentDisposition != nil {
w.Header().Set("Content-Disposition", *result.ContentDisposition)
}
if result.ContentEncoding != nil {
w.Header().Set("Content-Encoding", *result.ContentEncoding)
}
if result.ContentLanguage != nil {
w.Header().Set("Content-Language", *result.ContentLanguage)
}
if result.ContentLength != nil {
w.Header().Set("Content-Length", strconv.FormatInt(*result.ContentLength, 10))
}
if result.ContentRange != nil {
w.Header().Set("Content-Range", *result.ContentRange)
}
if result.ContentType != nil {
w.Header().Set("Content-Type", *result.ContentType)
}
if result.CacheControl != nil {
w.Header().Set("Cache-Control", *result.CacheControl)
}
if result.ETag != nil && *result.ETag != "" {
w.Header().Set("ETag", string(*result.ETag))
}
if result.LastModified != nil {
w.Header().Set("Last-Modified", result.LastModified.Format(http.TimeFormat))
}

w.WriteHeader(statusCode)

_, err = io.Copy(w, result.Body)
return err
}

func (blockBlob *BlockBlob) GetOffset(ctx context.Context) (int64, error) {
// Get the offset of the file from azure storage
// For the blob, show each block (ID and size) that is a committed part of it.
Expand Down Expand Up @@ -260,6 +322,11 @@ func (infoBlob *InfoBlob) Download(ctx context.Context) (io.ReadCloser, error) {
return resp.Body, nil
}

// ServeContent is not needed for infoBlob
func (infoBlob *InfoBlob) ServeContent(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
return errors.New("azurestore: ServeContent is not implemented for InfoBlob")
}

// infoBlob does not utilise offset, so just return 0, nil
func (infoBlob *InfoBlob) GetOffset(ctx context.Context) (int64, error) {
return 0, nil
Expand Down Expand Up @@ -316,3 +383,47 @@ func checkForNotFoundError(err error) error {
}
return err
}

// parse the Range, If-Match, If-None-Match, If-Unmodified-Since, If-Modified-Since headers if present
func ParseDownloadOptions(r *http.Request) (*azblob.DownloadStreamOptions, error) {
input := azblob.DownloadStreamOptions{AccessConditions: &azblob.AccessConditions{}}

if val := r.Header.Get("Range"); val != "" {
// zero value count indicates from the offset to the resource's end, suffix-length is not required
input.Range = azblob.HTTPRange{Offset: 0, Count: 0}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does Azure support fetching multiple ranges in a single request? I.e. using Range: <unit>=<range-start>-<range-end>, …, <range-startN>-<range-endN> (from https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Range). S3 doesn't so the s3store doesn't support it. We can do the same here, but I was just wondering.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, thank you for getting back :)
Response headers are forwarded now.
Regarding Range Header handling, what is missing, give me some hints?

Regarding multiple range in a single request, that is not supported. see also https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/storage/[email protected]/internal/exported#HTTPRange

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding multiple range in a single request, that is not supported. see also https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/storage/[email protected]/internal/exported#HTTPRange

Ok, I see. Can you please add a comment mentioning that Azure doesn't support multiple ranges and thus azurestore falls back to fetching the full resource, just like s3store does?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response headers are forwarded now.

Great, thanks you!

bytesEnd := 0
if _, err := fmt.Sscanf(val, "bytes=%d-%d", &input.Range.Offset, &bytesEnd); err != nil {
if _, err := fmt.Sscanf(val, "bytes=%d-", &input.Range.Offset); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to be handling requests where only the last bytes are requested using Range: <unit>=-<suffix-length>. Is that supported by Azure?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that isn't supported either. how should this be handled? throw an error/map to a different httprange?

It would be nice, if you could pull it across the finish line, I'm willing to do the testing :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see that the Azure SDK doesn't support this natively: https://github.com/Azure/azure-sdk-for-go/blob/f29a1d52c4e6894a536b0da18ac4399692e02c4c/sdk/storage/azblob/internal/exported/exported.go#L23

However, I think the corresponding AzUpload might have the total size of the object already in its info object. So we could calculate the corresponding offset based on the size as offset = size - suffix-length (and maybe -1 depending on how the bytes are counted, not sure).

I don't want to drag this on too long, but if there is an easy way to support fetching trailing bytes, I think it would be great to have it :)

return nil, err
}
}
if bytesEnd != 0 {
input.Range.Count = int64(bytesEnd) - input.Range.Offset + 1
}
}
if val := r.Header.Get("If-Match"); val != "" {
etagIfMatch := azcore.ETag(val)
input.AccessConditions.ModifiedAccessConditions.IfMatch = &etagIfMatch
}
if val := r.Header.Get("If-None-Match"); val != "" {
etagIfNoneMatch := azcore.ETag(val)
input.AccessConditions.ModifiedAccessConditions.IfNoneMatch = &etagIfNoneMatch
}
if val := r.Header.Get("If-Modified-Since"); val != "" {
t, err := http.ParseTime(val)
if err != nil {
return nil, err
}
input.AccessConditions.ModifiedAccessConditions.IfModifiedSince = &t

}
if val := r.Header.Get("If-Unmodified-Since"); val != "" {
t, err := http.ParseTime(val)
if err != nil {
return nil, err
}
input.AccessConditions.ModifiedAccessConditions.IfUnmodifiedSince = &t
}

return &input, nil
}
11 changes: 11 additions & 0 deletions pkg/azurestore/azurestore.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"io/fs"
"net/http"
"os"
"strings"

Expand Down Expand Up @@ -47,6 +48,7 @@ func (store AzureStore) UseIn(composer *handler.StoreComposer) {
composer.UseCore(store)
composer.UseTerminater(store)
composer.UseLengthDeferrer(store)
composer.UseContentServer(store)
}

func (store AzureStore) NewUpload(ctx context.Context, info handler.FileInfo) (handler.Upload, error) {
Expand Down Expand Up @@ -149,6 +151,10 @@ func (store AzureStore) AsLengthDeclarableUpload(upload handler.Upload) handler.
return upload.(*AzUpload)
}

func (store AzureStore) AsServableUpload(upload handler.Upload) handler.ServableUpload {
return upload.(*AzUpload)
}

func (upload *AzUpload) WriteChunk(ctx context.Context, offset int64, src io.Reader) (int64, error) {
// Create a temporary file for holding the uploaded data
file, err := os.CreateTemp(upload.tempDir, "tusd-az-tmp-")
Expand Down Expand Up @@ -214,6 +220,11 @@ func (upload *AzUpload) GetReader(ctx context.Context) (io.ReadCloser, error) {
return upload.BlockBlob.Download(ctx)
}

// Serves the contents of the blob directly handling special HTTP headers like Range, if set
func (upload *AzUpload) ServeContent(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
return upload.BlockBlob.ServeContent(ctx, w, r)
}

// Finish the file upload and commit the block list
func (upload *AzUpload) FinishUpload(ctx context.Context) error {
return upload.BlockBlob.Commit(ctx)
Expand Down
15 changes: 15 additions & 0 deletions pkg/azurestore/azurestore_mock_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

127 changes: 127 additions & 0 deletions pkg/azurestore/azurestore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -431,6 +434,130 @@ func TestDeclareLength(t *testing.T) {
cancel()
}

func TestAzureStoreAsServerDataStore(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
assert := assert.New(t)

service := NewMockAzService(mockCtrl)
store := azurestore.New(service)

mockUpload := &azurestore.AzUpload{}
servableUpload := store.AsServableUpload(mockUpload)

assert.NotNil(servableUpload)
assert.IsType(&azurestore.AzUpload{}, servableUpload)
}

func TestAZServableUploadServeContent(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
assert := assert.New(t)
ctx := context.Background()

blockBlob := NewMockAzBlob(mockCtrl)
assert.NotNil(blockBlob)

// Create a test HTTP request and response recorder
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()

// Expected response headers and body
expectedHeaders := map[string]string{
"Content-Type": "text/plain",
"Content-Length": "12",
"ETag": "bytes",
"CacheControl": "max-age=3600",
}
expectedBody := "test content"

// Mock ServeContent call
blockBlob.EXPECT().ServeContent(ctx, gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
// Add headers to response
for key, value := range expectedHeaders {
w.Header().Set(key, value)
}
w.WriteHeader(http.StatusOK)

// Write response body
_, err := w.Write([]byte(expectedBody))
return err
},
).Times(1)

err := blockBlob.ServeContent(ctx, rec, req)

assert.Nil(err)
assert.Equal(http.StatusOK, rec.Code)
for key, value := range expectedHeaders {
assert.Equal(value, rec.Header().Get(key))
}
assert.Equal(expectedBody, rec.Body.String())
}

func TestParseDownloadOptions(t *testing.T) {
tests := []struct {
name string
headers map[string]string
expected *azblob.DownloadStreamOptions
expectErr bool
}{
{
name: "Valid Range header",
headers: map[string]string{
"Range": "bytes=10-20",
},
expected: &azblob.DownloadStreamOptions{
Range: azblob.HTTPRange{
Offset: 10,
Count: 11,
},
},
expectErr: false,
},
{
name: "Valid Range header",
headers: map[string]string{
"Range": "bytes=10-",
},
expected: &azblob.DownloadStreamOptions{
Range: azblob.HTTPRange{
Offset: 10,
Count: 0,
},
},
expectErr: false,
},
{
name: "Valid Range header",
headers: map[string]string{
"Range": "bytes=zZ-",
},
expected: &azblob.DownloadStreamOptions{},
expectErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
for key, value := range tt.headers {
req.Header.Set(key, value)
}

options, err := azurestore.ParseDownloadOptions(req)
if tt.expectErr {
assert.NotNil(t, err)
} else {
assert.Nil(t, err)
options.AccessConditions = nil
assert.Equal(t, tt.expected, options)
}
})
}
}

func newReadCloser(b []byte) io.ReadCloser {
return io.NopCloser(bytes.NewReader(b))
}
Loading