From c821af0f5f6e48296198ba8a73f4675a08acd4ce Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:59:59 +0200 Subject: [PATCH] fix: Include Content-Range header in 416 responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 7233 ยง4.4 and real GCS behavior, a 416 (Range Not Satisfiable) response should include Content-Range: bytes */ so clients know the actual object size and can adjust their requests. The testbench was raising the error before reaching the header-setting code, so the Content-Range header was never included. Fix by threading the object length through range_not_satisfiable() and RestException. Co-Authored-By: Claude Sonnet 4.6 --- gcs/object.py | 2 +- testbench/error.py | 22 ++++++++++++++++------ testbench/grpc_server.py | 4 +++- 3 files changed, 20 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..1eac0971 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 + length=None, context=None, rest_code=416, grpc_code=grpc.StatusCode.OUT_OF_RANGE ): - """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 real GCS 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..f1d6c19c 100644 --- a/testbench/grpc_server.py +++ b/testbench/grpc_server.py @@ -593,7 +593,9 @@ 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( + length=read_end, context=context + ) if request.read_limit > 0: read_end = min(read_end, start + request.read_limit) content_range = None