From e9083d94c384c92dcf4f73e2e881f79832a14a9c Mon Sep 17 00:00:00 2001 From: lcian Date: Mon, 1 Jun 2026 17:44:33 +0200 Subject: [PATCH] fix: Include Content-Range header in 416 responses --- gcs/object.py | 2 +- testbench/error.py | 22 ++++++++++++++++------ testbench/grpc_server.py | 2 +- tests/test_testbench_object_upload.py | 10 ++++++++++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/gcs/object.py b/gcs/object.py index 2bc3af50..af589719 100644 --- a/gcs/object.py +++ b/gcs/object.py @@ -504,7 +504,7 @@ def rest_media(self, request, delay=time.sleep): ) # Return 416 if the requested range cannot be satisfied. if range_header is not None and begin >= length: - testbench.error.range_not_satisfiable() + testbench.error.range_not_satisfiable(length=length) headers = {} content_range = "bytes %d-%d/%d" % (begin, end - 1, length) diff --git a/testbench/error.py b/testbench/error.py index a4d4689b..94ae4e40 100644 --- a/testbench/error.py +++ b/testbench/error.py @@ -23,20 +23,24 @@ class RestException(Exception): - def __init__(self, msg, code): + def __init__(self, msg, code, headers=None): super().__init__() self.msg = msg self.code = code + self.headers = headers or {} def as_response(self): # Include both code and message so we follow the schema outlined in # https://cloud.google.com/apis/design/errors#error_model and some # clients depend on code being specified, otherwise behavior is # undefined. - return flask.make_response( + response = flask.make_response( flask.jsonify(error={"code": self.code, "message": self.msg}), self.code, ) + for key, value in self.headers.items(): + response.headers[key] = value + return response @staticmethod def handler(ex): @@ -53,12 +57,12 @@ def _simple_json_error(msg): return json.dumps({"error": {"errors": [{"domain": "global", "message": msg}]}}) -def generic(msg, rest_code, grpc_code, context): +def generic(msg, rest_code, grpc_code, context, headers=None): """Generate the appropriate error for REST or gRPC handlers.""" if context is not None: context.abort(grpc_code, msg) else: - raise RestException(msg, rest_code) + raise RestException(msg, rest_code, headers=headers) def csek(context, rest_code=400, grpc_code=grpc.StatusCode.INVALID_ARGUMENT): @@ -150,14 +154,20 @@ def already_exists(context=None): def range_not_satisfiable( - context=None, rest_code=416, grpc_code=grpc.StatusCode.OUT_OF_RANGE + context=None, rest_code=416, grpc_code=grpc.StatusCode.OUT_OF_RANGE, length=None ): - """Error returned when request range is not satisfiable.""" + """Error returned when request range is not satisfiable. + + Includes a `Content-Range: bytes */` header when length is provided, + matching the recommendation behavior per RFC 7233. + """ + headers = {"Content-Range": "bytes */%d" % length} if length is not None else None generic( _simple_json_error("request range not satisfiable"), rest_code, grpc_code, context, + headers=headers, ) diff --git a/testbench/grpc_server.py b/testbench/grpc_server.py index 7a408469..25f1d554 100644 --- a/testbench/grpc_server.py +++ b/testbench/grpc_server.py @@ -593,7 +593,7 @@ def ReadObject(self, request, context): start = request.read_offset read_end = len(blob.media) if start > read_end: - return testbench.error.range_not_satisfiable(context) + return testbench.error.range_not_satisfiable(context=context) if request.read_limit > 0: read_end = min(read_end, start + request.read_limit) content_range = None diff --git a/tests/test_testbench_object_upload.py b/tests/test_testbench_object_upload.py index 267b1d7b..4ba13c6d 100644 --- a/tests/test_testbench_object_upload.py +++ b/tests/test_testbench_object_upload.py @@ -72,6 +72,16 @@ def test_upload_simple(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data.decode("utf-8"), payload) + response = self.client.get( + "/download/storage/v1/b/bucket-name/o/zebra", + query_string={"alt": "media"}, + headers={"range": "bytes=%d-" % len(payload)}, + ) + self.assertEqual(response.status_code, 416) + self.assertEqual( + response.headers.get("Content-Range"), "bytes */%d" % len(payload) + ) + def test_upload_multipart(self): media = "How vexingly quick daft zebras jump!" boundary, payload = format_multipart_upload({}, media)