From 2becbe80fea3e05a98bf6e61281949e602944d20 Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Tue, 23 Jun 2026 10:14:42 +0000 Subject: [PATCH] Send an empty JSON body for parameterless POST/PUT/PATCH requests Generated methods for resource actions that take no parameters (for example KnowledgeAssistantsAPI.sync_knowledge_sources, a POST to ".../knowledge-sources:sync") set Content-Type: application/json but pass no body. requests serializes json=None as no request body at all, so these requests advertise JSON yet send nothing on the wire, and some Databricks API gateways reject them with a 400 BadRequest. Default the body to an empty object in _BaseClient.do when a POST/PUT/PATCH declares application/json and carries no body, data, or files, so the request is well-formed. This matches the Go SDK, which marshals an empty request to "{}". GET/DELETE/HEAD and requests that already carry a body are unaffected. Co-authored-by: Isaac --- databricks/sdk/_base_client.py | 18 +++++++++++++ tests/test_base_client.py | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/databricks/sdk/_base_client.py b/databricks/sdk/_base_client.py index 13ddb9bec..3108f05c2 100644 --- a/databricks/sdk/_base_client.py +++ b/databricks/sdk/_base_client.py @@ -162,6 +162,24 @@ def do( headers = {} headers["User-Agent"] = self._user_agent_base + # A request that advertises a JSON content type must still send a JSON body. Resource + # actions that take no parameters (e.g. ".../knowledge-sources:sync") set + # Content-Type: application/json but have no fields to serialize, so the caller passes + # no body. requests turns json=None into no request body at all, which some Databricks + # API gateways reject. Default the body to an empty object so the request is well-formed. + # The Content-Type comparison is intentionally an exact match: every generated call site + # emits this literal, so matching it narrowly avoids touching callers that set a variant. + # `data`/`files` are checked with `is None` because an explicitly supplied payload — even + # an empty one — means the caller is sending its own body, not requesting the default. + if ( + body is None + and data is None + and files is None + and method in ("POST", "PUT", "PATCH") + and headers.get("Content-Type") == "application/json" + ): + body = {} + # Wrap strings and bytes in a seekable stream so that we can rewind them. if isinstance(data, (str, bytes)): data = io.BytesIO(data.encode("utf-8") if isinstance(data, str) else data) diff --git a/tests/test_base_client.py b/tests/test_base_client.py index 1b8beed05..e0b66fb41 100644 --- a/tests/test_base_client.py +++ b/tests/test_base_client.py @@ -209,6 +209,53 @@ def test_api_client_do_custom_headers(requests_mock): assert res == {"well": "done"} +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) +def test_api_client_do_sends_empty_json_body_for_parameterless_request(requests_mock, method): + # A body-bearing request that advertises a JSON content type but has no body to serialize + # (resource actions like ".../knowledge-sources:sync") must still send a valid JSON body, + # otherwise requests sends no body at all and gateways reject the request. + client = _BaseClient() + requests_mock.register_uri(method, "https://localhost/sync", json={}) + client.do(method, "https://localhost/sync", headers={"Content-Type": "application/json"}) + assert requests_mock.last_request.text == "{}" + assert requests_mock.last_request.json() == {} + + +def test_api_client_do_keeps_no_body_when_no_json_content_type(requests_mock): + # POSTs that don't advertise application/json keep sending no body. + client = _BaseClient() + requests_mock.post("/action", json={}) + client.do("POST", "https://localhost/action") + assert requests_mock.last_request.body is None + + +def test_api_client_do_does_not_add_body_to_get(requests_mock): + # The empty-body default only applies to body-bearing verbs, never GET. + client = _BaseClient() + requests_mock.get("/list", json={"items": []}) + client.do("GET", "https://localhost/list", headers={"Content-Type": "application/json"}) + assert requests_mock.last_request.body is None + + +def test_api_client_do_does_not_add_body_when_files_given(requests_mock): + # An explicitly supplied payload — even an empty files= dict — means the caller is sending + # its own body, so the JSON default must not kick in. (requests treats an empty files= as + # "no multipart", so without the `is None` guard this would wrongly send "{}".) + client = _BaseClient() + requests_mock.post("/upload", json={}) + client.do("POST", "https://localhost/upload", headers={"Content-Type": "application/json"}, files={}) + assert requests_mock.last_request.body is None + + +def test_api_client_do_does_not_replace_explicit_falsy_body(requests_mock): + # An explicitly supplied falsy body (e.g. an empty list) is the caller's intent and must be + # sent verbatim, not clobbered to {} — guards against a `not body` regression of the guard. + client = _BaseClient() + requests_mock.post("/x", json={}) + client.do("POST", "https://localhost/x", headers={"Content-Type": "application/json"}, body=[]) + assert requests_mock.last_request.json() == [] + + @pytest.mark.parametrize( "status_code,include_retry_after", ((429, False), (429, True), (503, False), (503, True)),