@@ -272,7 +272,16 @@ def get_request_body(
272272 data : typing .Optional [typing .Any ],
273273 request_options : typing .Optional [RequestOptions ],
274274 omit : typing .Optional [typing .Any ],
275+ optional_body : bool = False ,
275276) -> typing .Tuple [typing .Optional [typing .Any ], typing .Optional [typing .Any ]]:
277+ # A whole body left at the sentinel was never passed by the caller, so it is absent
278+ # rather than empty: the request carries no content and no `Content-Type`.
279+ if omit is not None :
280+ if json is omit :
281+ json = None
282+ if data is omit :
283+ data = None
284+
276285 json_body = None
277286 data_body = None
278287 if data is not None :
@@ -288,14 +297,36 @@ def get_request_body(
288297 # Only collapse empty dict to None when the body was not explicitly provided
289298 # and there are no additional body parameters. This preserves explicit empty
290299 # bodies (e.g., when an endpoint has a request body type but all fields are optional).
291- if json_body == {} and json is None and not has_additional_body_parameters :
300+ # `optional_body` marks an endpoint whose body the API does not require, where a body
301+ # that ends up empty means the caller passed none of its properties, so the request is
302+ # sent with no content and no `Content-Type`.
303+ if json_body == {} and (json is None or optional_body ) and not has_additional_body_parameters :
292304 json_body = None
293- if data_body == {} and data is None and not has_additional_body_parameters :
305+ if data_body == {} and ( data is None or optional_body ) and not has_additional_body_parameters :
294306 data_body = None
295307
296308 return json_body , data_body
297309
298310
311+ def drop_content_type_without_body (
312+ headers : typing .Dict [str , typing .Any ],
313+ * ,
314+ json_body : typing .Optional [typing .Any ],
315+ data_body : typing .Optional [typing .Any ],
316+ optional_body : bool ,
317+ ) -> typing .Dict [str , typing .Any ]:
318+ """Strip ``Content-Type`` from a request that carries no body.
319+
320+ ``get_request_body`` drops the body of an ``optional_body`` endpoint when the caller
321+ supplied none of it, but the endpoint still passes the content type it would have used.
322+ A request that sends nothing must not advertise a media type, so a server that branches
323+ on the header sees a bodyless call for what it is.
324+ """
325+ if not optional_body or json_body is not None or data_body is not None :
326+ return headers
327+ return {key : value for key , value in headers .items () if key .lower () != "content-type" }
328+
329+
299330class HttpClient :
300331 def __init__ (
301332 self ,
@@ -343,6 +374,7 @@ def request(
343374 request_options : typing .Optional [RequestOptions ] = None ,
344375 retries : int = 0 ,
345376 omit : typing .Optional [typing .Any ] = None ,
377+ optional_body : bool = False ,
346378 force_multipart : typing .Optional [bool ] = None ,
347379 ) -> httpx .Response :
348380 base_url = self .get_base_url (base_url )
@@ -355,7 +387,9 @@ def request(
355387 )
356388 timeout = _timeout if _timeout is not None else httpx .USE_CLIENT_DEFAULT
357389
358- json_body , data_body = get_request_body (json = json , data = data , request_options = request_options , omit = omit )
390+ json_body , data_body = get_request_body (
391+ json = json , data = data , request_options = request_options , omit = omit , optional_body = optional_body
392+ )
359393
360394 request_files : typing .Optional [RequestFiles ] = (
361395 convert_file_dict_to_httpx_tuples (remove_omit_from_dict (remove_none_from_dict (files ), omit ))
@@ -398,6 +432,9 @@ def request(
398432 }
399433 )
400434 )
435+ _request_headers = drop_content_type_without_body (
436+ _request_headers , json_body = json_body , data_body = data_body , optional_body = optional_body
437+ )
401438
402439 if self .logger .is_debug ():
403440 self .logger .debug (
@@ -506,6 +543,7 @@ def stream(
506543 request_options : typing .Optional [RequestOptions ] = None ,
507544 retries : int = 0 ,
508545 omit : typing .Optional [typing .Any ] = None ,
546+ optional_body : bool = False ,
509547 force_multipart : typing .Optional [bool ] = None ,
510548 ) -> typing .Iterator [httpx .Response ]:
511549 base_url = self .get_base_url (base_url )
@@ -527,7 +565,9 @@ def stream(
527565 if (request_files is None or len (request_files ) == 0 ) and force_multipart :
528566 request_files = FORCE_MULTIPART
529567
530- json_body , data_body = get_request_body (json = json , data = data , request_options = request_options , omit = omit )
568+ json_body , data_body = get_request_body (
569+ json = json , data = data , request_options = request_options , omit = omit , optional_body = optional_body
570+ )
531571
532572 data_body = _maybe_filter_none_from_multipart_data (data_body , request_files , force_multipart )
533573
@@ -561,6 +601,9 @@ def stream(
561601 }
562602 )
563603 )
604+ _request_headers = drop_content_type_without_body (
605+ _request_headers , json_body = json_body , data_body = data_body , optional_body = optional_body
606+ )
564607
565608 if self .logger .is_debug ():
566609 self .logger .debug (
@@ -638,6 +681,7 @@ async def request(
638681 request_options : typing .Optional [RequestOptions ] = None ,
639682 retries : int = 0 ,
640683 omit : typing .Optional [typing .Any ] = None ,
684+ optional_body : bool = False ,
641685 force_multipart : typing .Optional [bool ] = None ,
642686 ) -> httpx .Response :
643687 base_url = self .get_base_url (base_url )
@@ -659,7 +703,9 @@ async def request(
659703 if (request_files is None or len (request_files ) == 0 ) and force_multipart :
660704 request_files = FORCE_MULTIPART
661705
662- json_body , data_body = get_request_body (json = json , data = data , request_options = request_options , omit = omit )
706+ json_body , data_body = get_request_body (
707+ json = json , data = data , request_options = request_options , omit = omit , optional_body = optional_body
708+ )
663709
664710 data_body = _maybe_filter_none_from_multipart_data (data_body , request_files , force_multipart )
665711
@@ -696,6 +742,9 @@ async def request(
696742 }
697743 )
698744 )
745+ _request_headers = drop_content_type_without_body (
746+ _request_headers , json_body = json_body , data_body = data_body , optional_body = optional_body
747+ )
699748
700749 if self .logger .is_debug ():
701750 self .logger .debug (
@@ -804,6 +853,7 @@ async def stream(
804853 request_options : typing .Optional [RequestOptions ] = None ,
805854 retries : int = 0 ,
806855 omit : typing .Optional [typing .Any ] = None ,
856+ optional_body : bool = False ,
807857 force_multipart : typing .Optional [bool ] = None ,
808858 ) -> typing .AsyncIterator [httpx .Response ]:
809859 base_url = self .get_base_url (base_url )
@@ -825,7 +875,9 @@ async def stream(
825875 if (request_files is None or len (request_files ) == 0 ) and force_multipart :
826876 request_files = FORCE_MULTIPART
827877
828- json_body , data_body = get_request_body (json = json , data = data , request_options = request_options , omit = omit )
878+ json_body , data_body = get_request_body (
879+ json = json , data = data , request_options = request_options , omit = omit , optional_body = optional_body
880+ )
829881
830882 data_body = _maybe_filter_none_from_multipart_data (data_body , request_files , force_multipart )
831883
@@ -862,6 +914,9 @@ async def stream(
862914 }
863915 )
864916 )
917+ _request_headers = drop_content_type_without_body (
918+ _request_headers , json_body = json_body , data_body = data_body , optional_body = optional_body
919+ )
865920
866921 if self .logger .is_debug ():
867922 self .logger .debug (
0 commit comments