@@ -252,6 +252,25 @@ def _is_expected_content_type(
252
252
return expected_content_type in response_content_type
253
253
254
254
255
+ def _warn_if_unclosed_payload (payload : payload .Payload , stacklevel : int = 2 ) -> None :
256
+ """Warn if the payload is not closed.
257
+
258
+ Callers must check that the body is a Payload before calling this method.
259
+
260
+ Args:
261
+ payload: The payload to check
262
+ stacklevel: Stack level for the warning (default 2 for direct callers)
263
+ """
264
+ if not payload .autoclose and not payload .consumed :
265
+ warnings .warn (
266
+ "The previous request body contains unclosed resources. "
267
+ "Use await request.update_body() instead of setting request.body "
268
+ "directly to properly close resources and avoid leaks." ,
269
+ ResourceWarning ,
270
+ stacklevel = stacklevel ,
271
+ )
272
+
273
+
255
274
class ClientRequest :
256
275
GET_METHODS = {
257
276
hdrs .METH_GET ,
@@ -268,7 +287,7 @@ class ClientRequest:
268
287
}
269
288
270
289
# Type of body depends on PAYLOAD_REGISTRY, which is dynamic.
271
- body : Any = b""
290
+ _body : Union [ None , payload . Payload ] = None
272
291
auth = None
273
292
response = None
274
293
@@ -439,6 +458,36 @@ def host(self) -> str:
439
458
def port (self ) -> Optional [int ]:
440
459
return self .url .port
441
460
461
+ @property
462
+ def body (self ) -> Union [bytes , payload .Payload ]:
463
+ """Request body."""
464
+ # empty body is represented as bytes for backwards compatibility
465
+ return self ._body or b""
466
+
467
+ @body .setter
468
+ def body (self , value : Any ) -> None :
469
+ """Set request body with warning for non-autoclose payloads.
470
+
471
+ WARNING: This setter must be called from within an event loop and is not
472
+ thread-safe. Setting body outside of an event loop may raise RuntimeError
473
+ when closing file-based payloads.
474
+
475
+ DEPRECATED: Direct assignment to body is deprecated and will be removed
476
+ in a future version. Use await update_body() instead for proper resource
477
+ management.
478
+ """
479
+ # Close existing payload if present
480
+ if self ._body is not None :
481
+ # Warn if the payload needs manual closing
482
+ # stacklevel=3: user code -> body setter -> _warn_if_unclosed_payload
483
+ _warn_if_unclosed_payload (self ._body , stacklevel = 3 )
484
+ # NOTE: In the future, when we remove sync close support,
485
+ # this setter will need to be removed and only the async
486
+ # update_body() method will be available. For now, we call
487
+ # _close() for backwards compatibility.
488
+ self ._body ._close ()
489
+ self ._update_body (value )
490
+
442
491
@property
443
492
def request_info (self ) -> RequestInfo :
444
493
headers : CIMultiDictProxy [str ] = CIMultiDictProxy (self .headers )
@@ -590,9 +639,12 @@ def update_transfer_encoding(self) -> None:
590
639
)
591
640
592
641
self .headers [hdrs .TRANSFER_ENCODING ] = "chunked"
593
- else :
594
- if hdrs .CONTENT_LENGTH not in self .headers :
595
- self .headers [hdrs .CONTENT_LENGTH ] = str (len (self .body ))
642
+ elif (
643
+ self ._body is not None
644
+ and hdrs .CONTENT_LENGTH not in self .headers
645
+ and (size := self ._body .size ) is not None
646
+ ):
647
+ self .headers [hdrs .CONTENT_LENGTH ] = str (size )
596
648
597
649
def update_auth (self , auth : Optional [BasicAuth ], trust_env : bool = False ) -> None :
598
650
"""Set basic auth."""
@@ -610,37 +662,120 @@ def update_auth(self, auth: Optional[BasicAuth], trust_env: bool = False) -> Non
610
662
611
663
self .headers [hdrs .AUTHORIZATION ] = auth .encode ()
612
664
613
- def update_body_from_data (self , body : Any ) -> None :
665
+ def update_body_from_data (self , body : Any , _stacklevel : int = 3 ) -> None :
666
+ """Update request body from data."""
667
+ if self ._body is not None :
668
+ _warn_if_unclosed_payload (self ._body , stacklevel = _stacklevel )
669
+
614
670
if body is None :
671
+ self ._body = None
615
672
return
616
673
617
674
# FormData
618
- if isinstance (body , FormData ):
619
- body = body ()
675
+ maybe_payload = body () if isinstance (body , FormData ) else body
620
676
621
677
try :
622
- body = payload .PAYLOAD_REGISTRY .get (body , disposition = None )
678
+ body_payload = payload .PAYLOAD_REGISTRY .get (maybe_payload , disposition = None )
623
679
except payload .LookupError :
624
- body = FormData (body )()
625
-
626
- self .body = body
680
+ body_payload = FormData (maybe_payload )() # type: ignore[arg-type]
627
681
682
+ self ._body = body_payload
628
683
# enable chunked encoding if needed
629
684
if not self .chunked and hdrs .CONTENT_LENGTH not in self .headers :
630
- if (size := body .size ) is not None :
685
+ if (size := body_payload .size ) is not None :
631
686
self .headers [hdrs .CONTENT_LENGTH ] = str (size )
632
687
else :
633
688
self .chunked = True
634
689
635
690
# copy payload headers
636
- assert body .headers
691
+ assert body_payload .headers
637
692
headers = self .headers
638
693
skip_headers = self ._skip_auto_headers
639
- for key , value in body .headers .items ():
694
+ for key , value in body_payload .headers .items ():
640
695
if key in headers or (skip_headers is not None and key in skip_headers ):
641
696
continue
642
697
headers [key ] = value
643
698
699
+ def _update_body (self , body : Any ) -> None :
700
+ """Update request body after its already been set."""
701
+ # Remove existing Content-Length header since body is changing
702
+ if hdrs .CONTENT_LENGTH in self .headers :
703
+ del self .headers [hdrs .CONTENT_LENGTH ]
704
+
705
+ # Remove existing Transfer-Encoding header to avoid conflicts
706
+ if self .chunked and hdrs .TRANSFER_ENCODING in self .headers :
707
+ del self .headers [hdrs .TRANSFER_ENCODING ]
708
+
709
+ # Now update the body using the existing method
710
+ # Called from _update_body, add 1 to stacklevel from caller
711
+ self .update_body_from_data (body , _stacklevel = 4 )
712
+
713
+ # Update transfer encoding headers if needed (same logic as __init__)
714
+ if body is not None or self .method not in self .GET_METHODS :
715
+ self .update_transfer_encoding ()
716
+
717
+ async def update_body (self , body : Any ) -> None :
718
+ """
719
+ Update request body and close previous payload if needed.
720
+
721
+ This method safely updates the request body by first closing any existing
722
+ payload to prevent resource leaks, then setting the new body.
723
+
724
+ IMPORTANT: Always use this method instead of setting request.body directly.
725
+ Direct assignment to request.body will leak resources if the previous body
726
+ contains file handles, streams, or other resources that need cleanup.
727
+
728
+ Args:
729
+ body: The new body content. Can be:
730
+ - bytes/bytearray: Raw binary data
731
+ - str: Text data (will be encoded using charset from Content-Type)
732
+ - FormData: Form data that will be encoded as multipart/form-data
733
+ - Payload: A pre-configured payload object
734
+ - AsyncIterable: An async iterable of bytes chunks
735
+ - File-like object: Will be read and sent as binary data
736
+ - None: Clears the body
737
+
738
+ Usage:
739
+ # CORRECT: Use update_body
740
+ await request.update_body(b"new request data")
741
+
742
+ # WRONG: Don't set body directly
743
+ # request.body = b"new request data" # This will leak resources!
744
+
745
+ # Update with form data
746
+ form_data = FormData()
747
+ form_data.add_field('field', 'value')
748
+ await request.update_body(form_data)
749
+
750
+ # Clear body
751
+ await request.update_body(None)
752
+
753
+ Note:
754
+ This method is async because it may need to close file handles or
755
+ other resources associated with the previous payload. Always await
756
+ this method to ensure proper cleanup.
757
+
758
+ Warning:
759
+ Setting request.body directly is highly discouraged and can lead to:
760
+ - Resource leaks (unclosed file handles, streams)
761
+ - Memory leaks (unreleased buffers)
762
+ - Unexpected behavior with streaming payloads
763
+
764
+ It is not recommended to change the payload type in middleware. If the
765
+ body was already set (e.g., as bytes), it's best to keep the same type
766
+ rather than converting it (e.g., to str) as this may result in unexpected
767
+ behavior.
768
+
769
+ See Also:
770
+ - update_body_from_data: Synchronous body update without cleanup
771
+ - body property: Direct body access (STRONGLY DISCOURAGED)
772
+
773
+ """
774
+ # Close existing payload if it exists and needs closing
775
+ if self ._body is not None :
776
+ await self ._body .close ()
777
+ self ._update_body (body )
778
+
644
779
def update_expect_continue (self , expect : bool = False ) -> None :
645
780
if expect :
646
781
self .headers [hdrs .EXPECT ] = "100-continue"
@@ -717,27 +852,14 @@ async def write_bytes(
717
852
protocol = conn .protocol
718
853
assert protocol is not None
719
854
try :
720
- if isinstance (self .body , payload .Payload ):
721
- # Specialized handling for Payload objects that know how to write themselves
722
- await self .body .write_with_length (writer , content_length )
723
- else :
724
- # Handle bytes/bytearray by converting to an iterable for consistent handling
725
- if isinstance (self .body , (bytes , bytearray )):
726
- self .body = (self .body ,)
727
-
728
- if content_length is None :
729
- # Write the entire body without length constraint
730
- for chunk in self .body :
731
- await writer .write (chunk )
732
- else :
733
- # Write with length constraint, respecting content_length limit
734
- # If the body is larger than content_length, we truncate it
735
- remaining_bytes = content_length
736
- for chunk in self .body :
737
- await writer .write (chunk [:remaining_bytes ])
738
- remaining_bytes -= len (chunk )
739
- if remaining_bytes <= 0 :
740
- break
855
+ # This should be a rare case but the
856
+ # self._body can be set to None while
857
+ # the task is being started or we wait above
858
+ # for the 100-continue response.
859
+ # The more likely case is we have an empty
860
+ # payload, but 100-continue is still expected.
861
+ if self ._body is not None :
862
+ await self ._body .write_with_length (writer , content_length )
741
863
except OSError as underlying_exc :
742
864
reraised_exc = underlying_exc
743
865
@@ -833,7 +955,7 @@ async def send(self, conn: "Connection") -> "ClientResponse":
833
955
await writer .write_headers (status_line , self .headers )
834
956
835
957
task : Optional ["asyncio.Task[None]" ]
836
- if self .body or self ._continue is not None or protocol .writing_paused :
958
+ if self ._body or self ._continue is not None or protocol .writing_paused :
837
959
coro = self .write_bytes (writer , conn , self ._get_content_length ())
838
960
if sys .version_info >= (3 , 12 ):
839
961
# Optimization for Python 3.12, try to write
0 commit comments