Repository: Neoteroi/BlackSheep
Pull request #319 (2023-03-12 15:50:11)

Fix several bugs and improve the client
- Fix several bugs and improve the client
- Upgrade `essentials-openapi` dependency

codecov-commenter: ## [Codecov](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) Report
Patch coverage: **`96.05`**% and project coverage change: **`-0.13`** :warning:
> Comparison is base [(`bfb5d67`)](https://codecov.io/gh/Neoteroi/BlackSheep/commit/bfb5d67352272d5e6161ce20731ee93d11c768d5?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 98.41% compared to head [(`dbeadf4`)](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 98.28%.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##               v1     #319      +/-   ##
==========================================
- Coverage   98.41%   98.28%   -0.13%     
==========================================
  Files          59       59              
  Lines        5661     5706      +45     
==========================================
+ Hits         5571     5608      +37     
- Misses         90       98       +8     
```


| [Impacted Files](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) | Coverage Δ | |
|---|---|---|
| [blacksheep/testing/helpers.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC90ZXN0aW5nL2hlbHBlcnMucHk=) | `100.00% <ø> (ø)` | |
| [blacksheep/client/connection.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jbGllbnQvY29ubmVjdGlvbi5weQ==) | `98.66% <88.00%> (-1.34%)` | :arrow_down: |
| [blacksheep/client/cookies.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jbGllbnQvY29va2llcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [blacksheep/client/pool.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jbGllbnQvcG9vbC5weQ==) | `100.00% <100.00%> (ø)` | |
| [blacksheep/client/session.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jbGllbnQvc2Vzc2lvbi5weQ==) | `99.60% <100.00%> (-0.40%)` | :arrow_down: |

... and [3 files with indirect coverage changes](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/Neoteroi/BlackSheep/pull/319?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).


Patch:
diff --git a/.gitignore b/.gitignore
index 1a5d1ef..2f40dd8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -124,4 +124,4 @@ itests/out/*
 example.html
 example.jpg
 nice-cat.jpg
-venvp310
+venv*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47d2f61..fde3d0c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.2.11] - 2023-03-12 :croissant:
+
+- Makes the `ClientSession` more user friendly, supporting headers defined as
+  `dict[str, str]` or `list[tuple[str, str]]`.
+- Improves the type annotations of the `ClientSession`.
+- Corrects a bug in the `ClientSession` that would cause a task lock when the
+  connection is lost while downloading files.
+- Corrects a bug in the `ClientSession` causing `set-cookie` headers to not be
+  properly handled during redirects.
+- Renames the client connection pool classes to remove the prefix "Client".
+- Corrects bug of the `Request` class that would prevent setting `url` using a
+  string instead of an instance of `URL`.
+- Corrects bug of the `Request` class that prevented the `host` property from
+  working properly after updating `url` (causing `follow_redirects` to not work
+  properly in `ClientSession`).
+- Upgrades the `essentials-openapi` dependency, fixing [#316](https://github.com/Neoteroi/BlackSheep/issues/316).
+- Corrects the `Request` class to not generate more than one `Cookie` header
+  when multiple cookies are set, to [respect the specification](https://www.rfc-editor.org/rfc/rfc6265#section-5.4).
+
 ## [1.2.10] - 2023-02-17 :heart:
 - Fixes bug #305 (`ClientSession ssl=False` not working as intended).
 - Applies formatting from the most recent of Black.
diff --git a/blacksheep/client/connection.py b/blacksheep/client/connection.py
index 0456fb6..2811e79 100644
--- a/blacksheep/client/connection.py
+++ b/blacksheep/client/connection.py
@@ -33,6 +33,26 @@ class IncomingContent(Content):
         self._body = bytearray()
         self._chunk = asyncio.Event()
         self.complete = asyncio.Event()
+        self._exc: Optional[Exception] = None
+
+    @property
+    def exc(self) -> Optional[Exception]:
+        """
+        Gets an exception that was set on this content.
+        """
+        return self._exc
+
+    @exc.setter
+    def exc(self, value: Optional[Exception]):
+        """
+        Sets an exception on this content. The exception is used and raised if the
+        caller code is handling a response stream.
+        """
+        self._exc = value
+
+        if value:
+            # resume the loop for streaming content
+            self._chunk.set()
 
     def extend_body(self, chunk: bytes):
         self._body.extend(chunk)
@@ -43,23 +63,52 @@ class IncomingContent(Content):
             await self._chunk.wait()
             self._chunk.clear()
 
+            if not self._body:
+                break
+
             yield bytes(self._body)
             self._body.clear()
 
             if self.complete.is_set():
                 break
 
+            if self._exc:
+                raise self._exc
+
     async def read(self):
         await self.complete.wait()
         return bytes(self._body)
 
 
-class ConnectionClosedError(Exception):
+class ConnectionException(Exception):
+    """
+    Base class for client connections errors.
+    """
+
+
+class ConnectionClosedError(ConnectionException):
+    """
+    Exception raised when a connection that should be open is closed. The connection can
+    have been closed by the remote server or the client.
+    """
+
     def __init__(self, can_retry: bool):
         super().__init__("The connection was closed by the remote server.")
         self.can_retry = can_retry
 
 
+class ConnectionLostError(ConnectionException):
+    """
+    Exception raised when a connection is lost. This can happen for example because of
+    instable internet connection on the client. The client should retry repeating a
+    request - this does not happen always automatically since the client might be using
+    the response stream handling chunks.
+    """
+
+    def __init__(self):
+        super().__init__("The connection with the remote server was lost.")
+
+
 class InvalidResponseFromServer(Exception):
     def __init__(self, inner_exception, message=None):
         super().__init__(
@@ -241,6 +290,12 @@ class ClientConnection(asyncio.Protocol):
     def close(self) -> None:
         if self.open:
             self.open = False
+
+            if self.response is not None and isinstance(
+                self.response.content, IncomingContent
+            ):
+                self.response.content.exc = ConnectionClosedError(True)
+
             if self.transport:
                 self.transport.close()
 
@@ -259,6 +314,12 @@ class ClientConnection(asyncio.Protocol):
         self.ready.clear()
         self.open = False
 
+        # if the client was handling a stream, we need to stop the loop
+        if self.response is not None and isinstance(
+            self.response.content, IncomingContent
+        ):
+            self.response.content.exc = ConnectionLostError()
+
         if self._pending_task:
             self.response_ready.set()
 
@@ -303,7 +364,9 @@ class ClientConnection(asyncio.Protocol):
 
     def on_message_complete(self) -> None:
         if self.response and self.response.content:
+            assert isinstance(self.response.content, IncomingContent)
             self.response.content.complete.set()
+            self.response.content.extend_body(b"")
 
         if self._pending_task:
             # the server returned a response before we ended sending the
@@ -342,4 +405,7 @@ class ClientConnection(asyncio.Protocol):
             self.close()
 
     def on_body(self, value: bytes) -> None:
+        assert self.response is not None and isinstance(
+            self.response.content, IncomingContent
+        )
         self.response.content.extend_body(value)
diff --git a/blacksheep/client/cookies.py b/blacksheep/client/cookies.py
index 9fb04db..a588845 100644
--- a/blacksheep/client/cookies.py
+++ b/blacksheep/client/cookies.py
@@ -31,6 +31,10 @@ def not_ip_address(value: str):
     return False
 
 
+def is_ip_address(value: str):
+    return not not_ip_address(value)
+
+
 class StoredCookie:
     __slots__ = ("cookie", "persistent", "creation_time", "expiry_time")
 
@@ -102,7 +106,7 @@ class CookieJar:
 
         cookie_domain = cookie.domain.lstrip(".").lower()
 
-        if not not_ip_address(cookie_domain):
+        if is_ip_address(cookie_domain):
             # ignore
             return request_domain
 
@@ -111,7 +115,7 @@ class CookieJar:
             return request_domain
 
         if not request_domain.endswith(cookie_domain):
-            client_logger.warning(
+            client_logger.debug(
                 f"A response for {request_url.value} tried to set "
                 f"a cookie with domain {cookie_domain}; this could "
                 f"be a malicious action."
diff --git a/blacksheep/client/pool.py b/blacksheep/client/pool.py
index 994eced..320f621 100644
--- a/blacksheep/client/pool.py
+++ b/blacksheep/client/pool.py
@@ -5,6 +5,7 @@ from ssl import SSLContext
 from typing import Dict, Optional, Tuple, Union
 
 from blacksheep.exceptions import InvalidArgument
+from blacksheep.utils.aio import get_running_loop
 
 from .connection import INSECURE_SSLCONTEXT, SECURE_SSLCONTEXT, ClientConnection
 
@@ -30,7 +31,7 @@ def get_ssl_context(
     return None
 
 
-class ClientConnectionPool:
+class ConnectionPool:
     def __init__(
         self,
         loop: AbstractEventLoop,
@@ -80,7 +81,7 @@ class ClientConnectionPool:
 
     async def create_connection(self) -> ClientConnection:
         logger.debug(f"Creating connection to: {self.host}:{self.port}")
-        transport, connection = await self.loop.create_connection(
+        _, connection = await self.loop.create_connection(
             lambda: ClientConnection(self.loop, self),
             self.host,
             self.port,
@@ -109,10 +110,10 @@ class ClientConnectionPool:
                 connection.close()
 
 
-class ClientConnectionPools:
-    def __init__(self, loop: AbstractEventLoop) -> None:
-        self.loop = loop
-        self._pools: Dict[Tuple[bytes, bytes, int], ClientConnectionPool] = {}
+class ConnectionPools:
+    def __init__(self, loop: Optional[AbstractEventLoop] = None) -> None:
+        self.loop = loop or get_running_loop()
+        self._pools: Dict[Tuple[bytes, bytes, int], ConnectionPool] = {}
 
     def get_pool(self, scheme, host, port, ssl):
         assert scheme in (b"http", b"https"), "URL schema must be http or https"
@@ -123,11 +124,16 @@ class ClientConnectionPools:
         try:
             return self._pools[key]
         except KeyError:
-            new_pool = ClientConnectionPool(self.loop, scheme, host, port, ssl)
+            new_pool = ConnectionPool(self.loop, scheme, host, port, ssl)
             self._pools[key] = new_pool
             return new_pool
 
     def dispose(self):
-        for _, pool in self._pools.items():
+        for pool in self._pools.values():
             pool.dispose()
         self._pools.clear()
+
+
+# For backward compatibility. Aliases will be removed in the future.
+ClientConnectionPool = ConnectionPool
+ClientConnectionPools = ConnectionPools
diff --git a/blacksheep/client/session.py b/blacksheep/client/session.py
index efe4696..c39ae60 100644
--- a/blacksheep/client/session.py
+++ b/blacksheep/client/session.py
@@ -1,7 +1,19 @@
 import asyncio
 import ssl
 from asyncio import AbstractEventLoop, TimeoutError
-from typing import Any, AnyStr, Callable, Dict, List, Optional, Tuple, Type, Union, cast
+from typing import (
+    Any,
+    AnyStr,
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    Union,
+    cast,
+)
 from urllib.parse import urlencode
 
 from blacksheep import URL, Content, InvalidURL, Request, Response
@@ -18,10 +30,32 @@ from .exceptions import (
     RequestTimeout,
     UnsupportedRedirect,
 )
-from .pool import ClientConnection, ClientConnectionPools
+from .pool import ClientConnection, ConnectionPools
 
 URLType = Union[str, bytes, URL]
-Header = Tuple[bytes, bytes]
+KeyValuePair = Tuple[AnyStr, AnyStr]
+Headers = Union[Dict[AnyStr, AnyStr], Iterable[KeyValuePair]]
+Params = Union[Dict[AnyStr, AnyStr], Iterable[KeyValuePair]]
+
+
+def _ensure_header_bytes(value: AnyStr) -> bytes:
+    return value if isinstance(value, bytes) else value.encode("ascii")
+
+
+def normalize_headers(
+    headers: Optional[Headers],
+) -> Optional[List[Tuple[bytes, bytes]]]:
+    if headers is None:
+        return None
+    if isinstance(headers, dict):
+        return [
+            (_ensure_header_bytes(key), _ensure_header_bytes(value))
+            for key, value in headers.items()
+        ]
+    return [
+        (_ensure_header_bytes(key), _ensure_header_bytes(value))
+        for key, value in headers
+    ]
 
 
 class RedirectsCache:
@@ -67,7 +101,7 @@ def _get_default_pools_for_loop(loop):
     try:
         return _default_pools_by_loop_id[loop_id]
     except KeyError:
-        pools = ClientConnectionPools(loop)
+        pools = ConnectionPools(loop)
         _default_pools_by_loop_id[loop_id] = pools
         return pools
 
@@ -78,8 +112,8 @@ class ClientSession:
         loop: Optional[AbstractEventLoop] = None,
         base_url: Union[None, bytes, str, URL] = None,
         ssl: Union[None, bool, ssl.SSLContext] = None,
-        pools: Optional[ClientConnectionPools] = None,
-        default_headers: Optional[List[Header]] = None,
+        pools: Optional[ConnectionPools] = None,
+        default_headers: Optional[Headers] = None,
         follow_redirects: bool = True,
         connection_timeout: float = 10.0,
         request_timeout: float = 60.0,
@@ -117,6 +151,7 @@ class ClientSession:
         self._base_url: Optional[URL]
         self.base_url = base_url
         self.ssl = ssl
+        self._default_headers: Optional[List[Tuple[bytes, bytes]]]
         self.default_headers = default_headers
         self.pools = pools
         self.connection_timeout = connection_timeout
@@ -133,6 +168,14 @@ class ClientSession:
         self.middlewares = middlewares
         self.delay_before_retry = 0.5
 
+    @property
+    def default_headers(self) -> Optional[List[Tuple[bytes, bytes]]]:
+        return self._default_headers
+
+    @default_headers.setter
+    def default_headers(self, value: Optional[Headers]):
+        self._default_headers = normalize_headers(value)
+
     @property
     def middlewares(self) -> List[Callable[..., Any]]:
         return self._middlewares
@@ -173,7 +216,7 @@ class ClientSession:
         """Uses specification compliant handling of 301 and 302 redirects"""
         self.non_standard_handling_of_301_302_redirect_method = False
 
-    def get_url(self, url, params=None) -> bytes:
+    def get_url(self, url, params: Optional[Params] = None) -> bytes:
         value = self.get_url_value(url)
         if not params:
             return value
@@ -253,7 +296,10 @@ class ClientSession:
         status = response.status
 
         if status == 301 or status == 302:
-            if self.non_standard_handling_of_301_302_redirect_method:
+            if (
+                self.non_standard_handling_of_301_302_redirect_method
+                and request.method != "GET"
+            ):
                 # Change original request method to GET (Browser-like)
                 request.method = "GET"
 
@@ -277,7 +323,6 @@ class ClientSession:
             return
 
         for header in self.default_headers:
-            # TODO: support tuple and Header?
             if header[0] not in request.headers:
                 request.headers.add(header[0], header[1])
 
@@ -323,15 +368,10 @@ class ClientSession:
 
         if self._handler:
             # using middlewares
-            return await self._handler(request)
-
-        # without middlewares
-        return await self._send_core(request)
-
-    async def _send_core(self, request: Request) -> Response:
-        self.check_permanent_redirects(request)
-
-        response = await self._send_using_connection(request)
+            response = await self._handler(request)
+        else:
+            # without middlewares
+            response = await self._send_core(request)
 
         if self.follow_redirects and response.is_redirect():
             try:
@@ -344,6 +384,11 @@ class ClientSession:
 
         return response
 
+    async def _send_core(self, request: Request) -> Response:
+        self.check_permanent_redirects(request)
+
+        return await self._send_using_connection(request)
+
     async def _send_using_connection(self, request, attempt: int = 1) -> Response:
         connection = await self.get_connection(request.url)
 
@@ -360,18 +405,23 @@ class ClientSession:
             raise RequestTimeout(request.url, self.request_timeout)
 
     async def get(
-        self, url: URLType, headers: Optional[List[Header]] = None, params=None
+        self,
+        url: URLType,
+        headers: Optional[Headers] = None,
+        params: Optional[Params] = None,
     ) -> Response:
-        return await self.send(Request("GET", self.get_url(url, params), headers))
+        return await self.send(
+            Request("GET", self.get_url(url, params), normalize_headers(headers))
+        )
 
     async def post(
         self,
         url: URLType,
-        content: Content = None,
-        headers: Optional[List[Header]] = None,
-        params=None,
+        content: Optional[Content] = None,
+        headers: Optional[Headers] = None,
+        params: Optional[Params] = None,
     ) -> Response:
-        request = Request("POST", self.get_url(url, params), headers)
+        request = Request("POST", self.get_url(url, params), normalize_headers(headers))
         return await self.send(
             request.with_content(content) if content is not None else request
         )
@@ -379,11 +429,11 @@ class ClientSession:
     async def put(
         self,
         url: URLType,
-        content: Content = None,
-        headers: Optional[List[Header]] = None,
-        params=None,
+        content: Optional[Content] = None,
+        headers: Optional[Headers] = None,
+        params: Optional[Params] = None,
     ) -> Response:
-        request = Request("PUT", self.get_url(url, params), headers)
+        request = Request("PUT", self.get_url(url, params), normalize_headers(headers))
         return await self.send(
             request.with_content(content) if content is not None else request
         )
@@ -391,33 +441,47 @@ class ClientSession:
     async def delete(
         self,
         url: URLType,
-        content: Content = None,
-        headers: Optional[List[Header]] = None,
-        params=None,
+        content: Optional[Content] = None,
+        headers: Optional[Headers] = None,
+        params: Optional[Params] = None,
     ) -> Response:
-        request = Request("DELETE", self.get_url(url, params), headers)
+        request = Request(
+            "DELETE", self.get_url(url, params), normalize_headers(headers)
+        )
         return await self.send(
             request.with_content(content) if content is not None else request
         )
 
     async def trace(
-        self, url: URLType, headers: Optional[List[Header]] = None, params=None
+        self,
+        url: URLType,
+        headers: Optional[Headers] = None,
+        params: Optional[Params] = None,
     ) -> Response:
-        return await self.send(Request("TRACE", self.get_url(url, params), headers))
+        return await self.send(
+            Request("TRACE", self.get_url(url, params), normalize_headers(headers))
+        )
 
     async def head(
-        self, url: URLType, headers: Optional[List[Header]] = None, params=None
+        self,
+        url: URLType,
+        headers: Optional[Headers] = None,
+        params: Optional[Params] = None,
     ) -> Response:
-        return await self.send(Request("HEAD", self.get_url(url, params), headers))
+        return await self.send(
+            Request("HEAD", self.get_url(url, params), normalize_headers(headers))
+        )
 
     async def patch(
         self,
         url: URLType,
-        content: Content = None,
-        headers: Optional[List[Header]] = None,
-        params=None,
+        content: Optional[Content] = None,
+        headers: Optional[Headers] = None,
+        params: Optional[Params] = None,
     ) -> Response:
-        request = Request("PATCH", self.get_url(url, params), headers)
+        request = Request(
+            "PATCH", self.get_url(url, params), normalize_headers(headers)
+        )
         return await self.send(
             request.with_content(content) if content is not None else request
         )
@@ -425,11 +489,13 @@ class ClientSession:
     async def options(
         self,
         url: URLType,
-        content: Content = None,
-        headers: Optional[List[Header]] = None,
-        params=None,
+        content: Optional[Content] = None,
+        headers: Optional[Headers] = None,
+        params: Optional[Params] = None,
     ) -> Response:
-        request = Request("OPTIONS", self.get_url(url, params), headers)
+        request = Request(
+            "OPTIONS", self.get_url(url, params), normalize_headers(headers)
+        )
         return await self.send(
             request.with_content(content) if content is not None else request
         )
diff --git a/blacksheep/messages.pyi b/blacksheep/messages.pyi
index a7d476d..191b354 100644
--- a/blacksheep/messages.pyi
+++ b/blacksheep/messages.pyi
@@ -85,7 +85,11 @@ class Request(Message):
     @property
     def cookies(self) -> Dict[str, str]: ...
     def get_cookie(self, name: str) -> Optional[str]: ...
-    def set_cookie(self, name: str, value: str) -> None: ...
+    def set_cookie(self, name: str, value: str) -> None:
+        """
+        Sets a cookie in the request. This method also ensures that a single
+        `cookie` header is set on the request.
+        """
     @property
     def etag(self) -> Optional[bytes]: ...
     @property
diff --git a/blacksheep/messages.pyx b/blacksheep/messages.pyx
index 48e67ef..86d5f68 100644
--- a/blacksheep/messages.pyx
+++ b/blacksheep/messages.pyx
@@ -388,9 +388,9 @@ cdef class Request(Message):
         if value:
             if isinstance(value, bytes):
                 _url = URL(value)
-            if isinstance(value, str):
+            elif isinstance(value, str):
                 _url = URL(value.encode('utf8'))
-            if isinstance(value, URL):
+            elif isinstance(value, URL):
                 _url = value
             else:
                 raise TypeError('Invalid value type, expected bytes, str, or URL')
@@ -404,6 +404,9 @@ cdef class Request(Message):
             self._path = None
             self._raw_query = None
         self._url = _url
+        # unset the cached host
+        self.__dict__["host"] = None
+        self.remove_header(b"host")
 
     def __repr__(self):
         return f'<Request {self.method} {self.url.value.decode()}>'
@@ -436,17 +439,28 @@ cdef class Request(Message):
         return self.cookies.get(name)
 
     def set_cookie(self, str name, str value):
-        self.__headers.append(
-            (b'cookie', (quote(name) + '=' + quote(value)).encode())
-        )
+        """
+        Sets a cookie in the request. This method also ensures that a single
+        `cookie` header is set on the request.
+        """
+        cdef bytes new_value
+        cdef bytes existing_cookie
+
+        new_value = (quote(name) + "=" + quote(value)).encode()
+        existing_cookie = self.get_first_header(b"cookie")
+
+        if existing_cookie:
+            self.set_header(b"cookie", existing_cookie + b";" + new_value)
+        else:
+            self.__headers.append((b"cookie", new_value))
 
     @property
     def etag(self):
-        return self.get_first_header(b'etag')
+        return self.get_first_header(b"etag")
 
     @property
     def if_none_match(self):
-        return self.get_first_header(b'if-none-match')
+        return self.get_first_header(b"if-none-match")
 
     cpdef bint expect_100_continue(self):
         cdef bytes value
diff --git a/blacksheep/scribe.pyi b/blacksheep/scribe.pyi
index c82eff2..77ad57c 100644
--- a/blacksheep/scribe.pyi
+++ b/blacksheep/scribe.pyi
@@ -9,9 +9,9 @@ def is_small_request(request: Request) -> bool: ...
 def request_has_body(request: Request) -> bool: ...
 def write_small_request(request: Request) -> bytes: ...
 def write_request_without_body(request: Request) -> bytes: ...
-async def write_chunks(content: Content): ...
+def write_chunks(content: Content) -> AsyncIterable[bytes]: ...
 async def send_asgi_response(response: Response, send: Callable): ...
-async def write_request(request: Request) -> AsyncIterable[bytes]: ...
-async def write_response(response: Response) -> AsyncIterable[bytes]: ...
-async def write_request_body_only(request: Request) -> AsyncIterable[bytes]: ...
+def write_request(request: Request) -> AsyncIterable[bytes]: ...
+def write_response(response: Response) -> AsyncIterable[bytes]: ...
+def write_request_body_only(request: Request) -> AsyncIterable[bytes]: ...
 def write_response_cookie(cookie: Cookie) -> bytes: ...
diff --git a/requirements.txt b/requirements.txt
index 99bc324..af8df0e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,7 +10,7 @@ coverage==6.5.0
 cryptography==38.0.1
 Cython==0.29.32; platform_system != "Windows"
 essentials==1.1.5
-essentials-openapi==1.0.3
+essentials-openapi==1.0.5
 Flask==2.2.2
 gevent==22.10.1
 greenlet==1.1.3.post0
diff --git a/setup.py b/setup.py
index bc9bf65..ef9d885 100644
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@ COMPILE_ARGS = ["-O2"]
 
 setup(
     name="blacksheep",
-    version="1.2.10",
+    version="1.2.11",
     description="Fast web framework for Python asyncio",
     long_description=readme(),
     long_description_content_type="text/markdown",
@@ -102,7 +102,7 @@ setup(
         "guardpost~=0.0.9",
         "rodi~=1.1.1",
         "essentials>=1.1.4,<2.0",
-        "essentials-openapi>=0.1.4,<1.0",
+        "essentials-openapi>=0.1.4,<1.1",
         "typing_extensions; python_version < '3.8'",
         "python-dateutil~=2.8.2",
         "itsdangerous~=2.1.2",


Test patch:
diff --git a/blacksheep/testing/helpers.py b/blacksheep/testing/helpers.py
index bca3a27..7170d80 100644
--- a/blacksheep/testing/helpers.py
+++ b/blacksheep/testing/helpers.py
@@ -31,8 +31,8 @@ def get_example_scope(
     *,
     query: QueryType = None,
     scheme: str = "http",
-    server: Union[List, Tuple[str, int]] = None,
-    client: Union[List, Tuple[str, int]] = None,
+    server: Union[None, List, Tuple[str, int]] = None,
+    client: Union[None, List, Tuple[str, int]] = None,
     user_agent: bytes = _DEFAULT_AGENT,
     accept: bytes = _DEFAULT_ACCEPT,
     accept_language: bytes = _DEFAULT_ACCEPT_LANGUAGE,
diff --git a/itests/client_fixtures.py b/itests/client_fixtures.py
index 7394f70..f1e2b96 100644
--- a/itests/client_fixtures.py
+++ b/itests/client_fixtures.py
@@ -7,7 +7,7 @@ from time import sleep
 import pytest
 
 from blacksheep.client import ClientSession
-from blacksheep.client.pool import ClientConnectionPools
+from blacksheep.client.pool import ConnectionPools
 from itests.utils import get_sleep_time
 
 from .flask_app import app
@@ -48,7 +48,7 @@ def session(server_url, event_loop):
     session = ClientSession(
         loop=event_loop,
         base_url=server_url,
-        pools=ClientConnectionPools(event_loop),
+        pools=ConnectionPools(event_loop),
     )
     yield session
     asyncio.run(session.close())
diff --git a/itests/flask_app.py b/itests/flask_app.py
index 825f5de..4b380e1 100644
--- a/itests/flask_app.py
+++ b/itests/flask_app.py
@@ -1,4 +1,4 @@
-from flask import Flask, jsonify, request
+from flask import Flask, jsonify, redirect, request
 from flask.wrappers import Response
 from markupsafe import escape
 
@@ -39,6 +39,23 @@ def close_connection():
     return response
 
 
+@app.route("/redirect-setting-cookie")
+def redirect_setting_cookie():
+    response = redirect("/redirect-requiring-cookie")
+    response.set_cookie("x-key", "example")
+    return response
+
+
+@app.route("/redirect-requiring-cookie")
+def redirect_requiring_cookie():
+    cookies = request.cookies
+    if cookies.get("x-key") == "example":
+        response = Response("Hello World", 200, mimetype="text/plain")
+    else:
+        response = Response("Unauthorized", 401, mimetype="text/plain")
+    return response
+
+
 @app.route("/echo-cookies")
 def echo_cookies():
     cookies = request.cookies
diff --git a/itests/test_client.py b/itests/test_client.py
index 05a7276..7fb35ee 100644
--- a/itests/test_client.py
+++ b/itests/test_client.py
@@ -27,12 +27,12 @@ async def test_get_plain_text(session):
 
 
 @pytest.mark.asyncio
-async def test_get_wikipedia_home(session):
+async def test_get_neoteroi_home(session):
     for _ in range(2):
-        response = await session.get("https://www.wikipedia.org")
+        response = await session.get("https://www.neoteroi.dev")
         ensure_success(response)
         text = await response.text()
-        assert "Wikipedia" in text
+        assert "Neoteroi" in text
 
 
 @pytest.mark.asyncio
@@ -248,3 +248,13 @@ async def test_close_connection(session):
     for _ in range(3):
         response = await session.get("/close-connection")
         ensure_success(response)
+
+
+@pytest.mark.asyncio
+async def test_cookies_with_redirect(session):
+    """
+    Tests proper handling of set-cookie header and client middlewares in general, when
+    handling redirects.
+    """
+    response = await session.get("/redirect-setting-cookie")
+    ensure_success(response)
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
index de1baec..5f1d1f8 100644
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -4,6 +4,7 @@ from blacksheep import URL, Request, Response
 from blacksheep.client import ClientSession
 from blacksheep.client.connection import ClientConnection, ConnectionClosedError
 from blacksheep.client.exceptions import UnsupportedRedirect
+from blacksheep.client.session import normalize_headers
 
 
 @pytest.mark.parametrize(
@@ -100,6 +101,7 @@ async def test_client_session_without_middlewares_and_cookiejar():
     async def monkey_send_core(request):
         nonlocal called
         called = True
+        return Response(200)
 
     async with ClientSession(cookie_jar=False) as client:
         assert client._handler is None
@@ -127,3 +129,33 @@ async def test_client_session_validate_url_for_relative_urls_with_base_url():
         client._validate_request_url(request)
 
         assert request.url == URL(b"https://foo.org/home")
+
+
+@pytest.mark.parametrize(
+    "value,expected_result",
+    [
+        [
+            [("accept", "gzip br")],
+            [(b"accept", b"gzip br")],
+        ],
+        [
+            [("accept", b"gzip br"), ("referrer", "http://neoteroi.dev")],
+            [(b"accept", b"gzip br"), (b"referrer", b"http://neoteroi.dev")],
+        ],
+        [
+            [(b"accept", b"gzip br")],
+            [(b"accept", b"gzip br")],
+        ],
+        [
+            {"X-Refresh-Token": "Example"},
+            [(b"X-Refresh-Token", b"Example")],
+        ],
+        [
+            {"x-one": "one", "x-two": "two", "x-three": "three"},
+            [(b"x-one", b"one"), (b"x-two", b"two"), (b"x-three", b"three")],
+        ],
+    ],
+)
+def test_normalize_headers(value, expected_result):
+    result = normalize_headers(value)
+    assert result == expected_result
diff --git a/tests/client/test_connection.py b/tests/client/test_connection.py
index 60cba90..92a92a8 100644
--- a/tests/client/test_connection.py
+++ b/tests/client/test_connection.py
@@ -13,7 +13,7 @@ from blacksheep.client.connection import (
     InvalidResponseFromServer,
     UpgradeResponse,
 )
-from blacksheep.client.pool import ClientConnectionPool
+from blacksheep.client.pool import ConnectionPool
 
 
 def get_example_headers():
@@ -46,7 +46,7 @@ def event_loop():
 
 @pytest.fixture(scope="module")
 def pool(event_loop):
-    pool = ClientConnectionPool(event_loop, b"http", b"foo.com", 80, None, max_size=2)
+    pool = ConnectionPool(event_loop, b"http", b"foo.com", 80, None, max_size=2)
     yield pool
     pool.dispose()
 
@@ -167,7 +167,7 @@ async def test_connection_handle_expect_100_continue_and_1xx(
     assert connection.transport.messages[1] == b'{"id":"1","name":"foo"}'
 
 
-class FakePoolThrowingOnRelease(ClientConnectionPool):
+class FakePoolThrowingOnRelease(ConnectionPool):
     def try_return_connection(self, connection: ClientConnection) -> None:
         raise Exception("Crash")
 
diff --git a/tests/client/test_pool.py b/tests/client/test_pool.py
index 8d6adaa..2739b87 100644
--- a/tests/client/test_pool.py
+++ b/tests/client/test_pool.py
@@ -7,7 +7,7 @@ from blacksheep.client.connection import (
     SECURE_SSLCONTEXT,
     ClientConnection,
 )
-from blacksheep.client.pool import ClientConnectionPool, get_ssl_context
+from blacksheep.client.pool import ConnectionPool, get_ssl_context
 from blacksheep.exceptions import InvalidArgument
 from blacksheep.utils.aio import get_running_loop
 
@@ -45,16 +45,14 @@ def test_get_ssl_context_raises_for_invalid_argument():
 
 
 def test_return_connection_disposed_pool_does_nothing():
-    pool = ClientConnectionPool(get_running_loop(), b"http", b"foo.com", 80, None)
+    pool = ConnectionPool(get_running_loop(), b"http", b"foo.com", 80, None)
 
     pool.dispose()
     pool.try_return_connection(ClientConnection(pool.loop, pool))
 
 
 def test_return_connection_does_nothing_if_the_queue_is_full():
-    pool = ClientConnectionPool(
-        get_running_loop(), b"http", b"foo.com", 80, None, max_size=2
-    )
+    pool = ConnectionPool(get_running_loop(), b"http", b"foo.com", 80, None, max_size=2)
 
     for i in range(5):
         pool.try_return_connection(ClientConnection(pool.loop, pool))
diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py
index 61e0c2e..f4a3a57 100644
--- a/tests/client/test_redirects.py
+++ b/tests/client/test_redirects.py
@@ -136,11 +136,11 @@ async def test_good_redirect(responses, expected_response_body, pools_factory):
 )
 async def test_not_follow_redirect(responses, expected_location, pools_factory):
     async with ClientSession(
-        base_url=b"http://localhost:8080",
+        base_url="http://localhost:8080",
         pools=pools_factory(responses),
         follow_redirects=False,
     ) as client:
-        response = await client.get(b"/")
+        response = await client.get("/")
 
         assert response.status == 302
 
diff --git a/tests/test_requests.py b/tests/test_requests.py
index fc26e59..42762ea 100644
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -4,7 +4,7 @@ from blacksheep import Content, Request, scribe
 from blacksheep.contents import FormPart, MultiPartFormData
 from blacksheep.exceptions import BadRequestFormat
 from blacksheep.messages import get_absolute_url_to_path, get_request_absolute_url
-from blacksheep.scribe import write_small_request
+from blacksheep.scribe import write_request, write_small_request
 from blacksheep.server.asgi import (
     get_request_url,
     get_request_url_from_scope,
@@ -490,3 +490,61 @@ def test_can_set_request_client_ip():
 
     assert request.original_client_ip == "185.152.122.103"
     assert scope["client"] == ("127.0.0.1", 51492)
+
+
+def test_updating_request_url_read_host():
+    request = Request("GET", b"https://www.neoteroi.dev/blacksheep", [])
+
+    assert request.path == "/blacksheep"
+    assert request.host == "www.neoteroi.dev"
+
+    request.url = "https://github.com/RobertoPrevato"
+
+    assert request.path == "/RobertoPrevato"
+    assert request.host == "github.com"
+
+
+@pytest.mark.asyncio
+async def test_updating_request_host_in_headers():
+    request = Request("GET", b"https://www.neoteroi.dev/blacksheep", [])
+
+    assert request.path == "/blacksheep"
+    assert request.host == "www.neoteroi.dev"
+    assert request.headers[b"host"] == tuple()
+
+    async for _ in write_request(request):
+        pass
+
+    assert request.headers[b"host"] == (b"www.neoteroi.dev",)
+    request.url = "https://github.com/RobertoPrevato"
+
+    assert request.headers[b"host"] == tuple()
+
+    async for _ in write_request(request):
+        pass
+
+    assert request.headers[b"host"] == (b"github.com",)
+
+
+@pytest.mark.asyncio
+async def test_write_request_cookies():
+    request = Request("GET", b"https://www.neoteroi.dev/blacksheep", [])
+
+    request.set_cookie("example_1", "one")
+    request.set_cookie("example_2", "two")
+
+    expected_bytes = b"""GET /blacksheep HTTP/1.1
+cookie: example_1=one;example_2=two
+host: www.neoteroi.dev
+content-length: 0
+
+""".replace(
+        b"\n", b"\r\n"
+    )
+
+    value = bytearray()
+
+    async for chunk in write_request(request):
+        value.extend(chunk)
+
+    assert bytes(value) == expected_bytes