]> git.ipfire.org Git - thirdparty/httpx.git/commitdiff
Event hooks (#1246)
authorTom Christie <tom@tomchristie.com>
Tue, 15 Sep 2020 11:05:39 +0000 (12:05 +0100)
committerGitHub <noreply@github.com>
Tue, 15 Sep 2020 11:05:39 +0000 (12:05 +0100)
* Add EventHooks internal datastructure

* Add support for 'request' and 'response' event hooks

* Support Client.event_hooks property

* Handle exceptions raised by response event hooks

* Docs for event hooks

* Only support 'request' and 'response' event hooks

* Add event_hooks to top-level API

* Event hooks

* Formatting

* Formatting

* Fix up event hooks test

* Add test case to confirm that redirects/event hooks don't currently play together correctly

* Refactor test cases

* Make response.request clear in response event hooks docs

* Drop merge marker

* Request event hook runs as soon as we have an auth-constructed request

docs/advanced.md
httpx/_client.py
tests/client/test_event_hooks.py [new file with mode: 0644]
tests/client/test_properties.py

index b0ed25fe1c6a9bb6ffd61561c2c962e5b674ab84..f374d0c93abd5fea397138b942fa1fdf9286dc9a 100644 (file)
@@ -221,6 +221,58 @@ with httpx.Client(headers=headers) as client:
     ...
 ```
 
+## Event Hooks
+
+HTTPX allows you to register "event hooks" with the client, that are called
+every time a particular type of event takes place.
+
+There are currently two event hooks:
+
+* `request` - Called once a request is about to be sent. Passed the `request` instance.
+* `response` - Called once the response has been returned. Passed the `response` instance.
+
+These allow you to install client-wide functionality such as logging and monitoring.
+
+```python
+def log_request(request):
+    print(f"Request event hook: {request.method} {request.url} - Waiting for response")
+
+def log_response(response):
+    request = response.request
+    print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}")
+
+client = httpx.Client(event_hooks={'request': [log_request], 'response': [log_response]})
+```
+
+You can also use these hooks to install response processing code, such as this
+example, which creates a client instance that always raises `httpx.HTTPStatusError`
+on 4xx and 5xx responses.
+
+```python
+def raise_on_4xx_5xx(response):
+    response.raise_for_status()
+
+client = httpx.Client(event_hooks={'response': [raise_on_4xx_5xx]})
+```
+
+Event hooks must always be set as a **list of callables**, and you may register
+multiple event hooks for each type of event.
+
+As well as being able to set event hooks on instantiating the client, there
+is also an `.event_hooks` property, that allows you to inspect and modify
+the installed hooks.
+
+```python
+client = httpx.Client()
+client.event_hooks['request'] = [log_request]
+client.event_hooks['response'] = [log_response, raise_for_status]
+```
+
+!!! note
+    If you are using HTTPX's async support, then you need to be aware that
+    hooks registered with `httpx.AsyncClient` MUST be async functions,
+    rather than plain functions.
+
 ## Monitoring download progress
 
 If you need to monitor download progress of large responses, you can use response streaming and inspect the `response.num_bytes_downloaded` property.
index 7a264318df340e67f068119ac4c7f09b577985c1..75992330059fdb7fb41fc3907131a33bb7ea5c30 100644 (file)
@@ -74,9 +74,12 @@ class BaseClient:
         cookies: CookieTypes = None,
         timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
         max_redirects: int = DEFAULT_MAX_REDIRECTS,
+        event_hooks: typing.Dict[str, typing.List[typing.Callable]] = None,
         base_url: URLTypes = "",
         trust_env: bool = True,
     ):
+        event_hooks = {} if event_hooks is None else event_hooks
+
         self._base_url = self._enforce_trailing_slash(URL(base_url))
 
         self._auth = self._build_auth(auth)
@@ -85,6 +88,10 @@ class BaseClient:
         self._cookies = Cookies(cookies)
         self._timeout = Timeout(timeout)
         self.max_redirects = max_redirects
+        self._event_hooks = {
+            "request": list(event_hooks.get("request", [])),
+            "response": list(event_hooks.get("response", [])),
+        }
         self._trust_env = trust_env
         self._netrc = NetRCInfo()
         self._is_closed = True
@@ -133,6 +140,19 @@ class BaseClient:
     def timeout(self, timeout: TimeoutTypes) -> None:
         self._timeout = Timeout(timeout)
 
+    @property
+    def event_hooks(self) -> typing.Dict[str, typing.List[typing.Callable]]:
+        return self._event_hooks
+
+    @event_hooks.setter
+    def event_hooks(
+        self, event_hooks: typing.Dict[str, typing.List[typing.Callable]]
+    ) -> None:
+        self._event_hooks = {
+            "request": list(event_hooks.get("request", [])),
+            "response": list(event_hooks.get("response", [])),
+        }
+
     @property
     def auth(self) -> typing.Optional[Auth]:
         """
@@ -532,6 +552,7 @@ class Client(BaseClient):
         limits: Limits = DEFAULT_LIMITS,
         pool_limits: Limits = None,
         max_redirects: int = DEFAULT_MAX_REDIRECTS,
+        event_hooks: typing.Dict[str, typing.List[typing.Callable]] = None,
         base_url: URLTypes = "",
         transport: httpcore.SyncHTTPTransport = None,
         app: typing.Callable = None,
@@ -544,6 +565,7 @@ class Client(BaseClient):
             cookies=cookies,
             timeout=timeout,
             max_redirects=max_redirects,
+            event_hooks=event_hooks,
             base_url=base_url,
             trust_env=trust_env,
         )
@@ -739,6 +761,13 @@ class Client(BaseClient):
             finally:
                 response.close()
 
+        try:
+            for hook in self._event_hooks["response"]:
+                hook(response)
+        except Exception:
+            response.close()
+            raise
+
         return response
 
     def _send_handling_auth(
@@ -752,6 +781,9 @@ class Client(BaseClient):
         auth_flow = auth.sync_auth_flow(request)
         request = next(auth_flow)
 
+        for hook in self._event_hooks["request"]:
+            hook(request)
+
         while True:
             response = self._send_handling_redirects(
                 request,
@@ -1153,6 +1185,7 @@ class AsyncClient(BaseClient):
         limits: Limits = DEFAULT_LIMITS,
         pool_limits: Limits = None,
         max_redirects: int = DEFAULT_MAX_REDIRECTS,
+        event_hooks: typing.Dict[str, typing.List[typing.Callable]] = None,
         base_url: URLTypes = "",
         transport: httpcore.AsyncHTTPTransport = None,
         app: typing.Callable = None,
@@ -1165,6 +1198,7 @@ class AsyncClient(BaseClient):
             cookies=cookies,
             timeout=timeout,
             max_redirects=max_redirects,
+            event_hooks=event_hooks,
             base_url=base_url,
             trust_env=trust_env,
         )
@@ -1362,6 +1396,13 @@ class AsyncClient(BaseClient):
             finally:
                 await response.aclose()
 
+        try:
+            for hook in self._event_hooks["response"]:
+                await hook(response)
+        except Exception:
+            await response.aclose()
+            raise
+
         return response
 
     async def _send_handling_auth(
@@ -1375,6 +1416,9 @@ class AsyncClient(BaseClient):
         auth_flow = auth.async_auth_flow(request)
         request = await auth_flow.__anext__()
 
+        for hook in self._event_hooks["request"]:
+            await hook(request)
+
         while True:
             response = await self._send_handling_redirects(
                 request,
diff --git a/tests/client/test_event_hooks.py b/tests/client/test_event_hooks.py
new file mode 100644 (file)
index 0000000..a2cfa93
--- /dev/null
@@ -0,0 +1,189 @@
+import pytest
+
+import httpx
+from tests.utils import AsyncMockTransport, MockTransport
+
+
+def app(request: httpx.Request) -> httpx.Response:
+    if request.url.path == "/redirect":
+        return httpx.Response(303, headers={"server": "testserver", "location": "/"})
+    elif request.url.path.startswith("/status/"):
+        status_code = int(request.url.path[-3:])
+        return httpx.Response(status_code, headers={"server": "testserver"})
+
+    return httpx.Response(200, headers={"server": "testserver"})
+
+
+def test_event_hooks():
+    events = []
+
+    def on_request(request):
+        events.append({"event": "request", "headers": dict(request.headers)})
+
+    def on_response(response):
+        events.append({"event": "response", "headers": dict(response.headers)})
+
+    event_hooks = {"request": [on_request], "response": [on_response]}
+
+    with httpx.Client(event_hooks=event_hooks, transport=MockTransport(app)) as http:
+        http.get("http://127.0.0.1:8000/", auth=("username", "password"))
+
+    assert events == [
+        {
+            "event": "request",
+            "headers": {
+                "host": "127.0.0.1:8000",
+                "user-agent": f"python-httpx/{httpx.__version__}",
+                "accept": "*/*",
+                "accept-encoding": "gzip, deflate, br",
+                "connection": "keep-alive",
+                "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
+            },
+        },
+        {
+            "event": "response",
+            "headers": {"server": "testserver"},
+        },
+    ]
+
+
+def test_event_hooks_raising_exception(server):
+    def raise_on_4xx_5xx(response):
+        response.raise_for_status()
+
+    event_hooks = {"response": [raise_on_4xx_5xx]}
+
+    with httpx.Client(event_hooks=event_hooks, transport=MockTransport(app)) as http:
+        try:
+            http.get("http://127.0.0.1:8000/status/400")
+        except httpx.HTTPStatusError as exc:
+            assert exc.response.is_closed
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_async_event_hooks():
+    events = []
+
+    async def on_request(request):
+        events.append({"event": "request", "headers": dict(request.headers)})
+
+    async def on_response(response):
+        events.append({"event": "response", "headers": dict(response.headers)})
+
+    event_hooks = {"request": [on_request], "response": [on_response]}
+
+    async with httpx.AsyncClient(
+        event_hooks=event_hooks, transport=AsyncMockTransport(app)
+    ) as http:
+        await http.get("http://127.0.0.1:8000/", auth=("username", "password"))
+
+    assert events == [
+        {
+            "event": "request",
+            "headers": {
+                "host": "127.0.0.1:8000",
+                "user-agent": f"python-httpx/{httpx.__version__}",
+                "accept": "*/*",
+                "accept-encoding": "gzip, deflate, br",
+                "connection": "keep-alive",
+                "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
+            },
+        },
+        {
+            "event": "response",
+            "headers": {"server": "testserver"},
+        },
+    ]
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_async_event_hooks_raising_exception():
+    async def raise_on_4xx_5xx(response):
+        response.raise_for_status()
+
+    event_hooks = {"response": [raise_on_4xx_5xx]}
+
+    async with httpx.AsyncClient(
+        event_hooks=event_hooks, transport=AsyncMockTransport(app)
+    ) as http:
+        try:
+            await http.get("http://127.0.0.1:8000/status/400")
+        except httpx.HTTPStatusError as exc:
+            assert exc.response.is_closed
+
+
+def test_event_hooks_with_redirect():
+    """
+    A redirect request should not trigger a second 'request' event hook.
+    """
+
+    events = []
+
+    def on_request(request):
+        events.append({"event": "request", "headers": dict(request.headers)})
+
+    def on_response(response):
+        events.append({"event": "response", "headers": dict(response.headers)})
+
+    event_hooks = {"request": [on_request], "response": [on_response]}
+
+    with httpx.Client(event_hooks=event_hooks, transport=MockTransport(app)) as http:
+        http.get("http://127.0.0.1:8000/redirect", auth=("username", "password"))
+
+    assert events == [
+        {
+            "event": "request",
+            "headers": {
+                "host": "127.0.0.1:8000",
+                "user-agent": f"python-httpx/{httpx.__version__}",
+                "accept": "*/*",
+                "accept-encoding": "gzip, deflate, br",
+                "connection": "keep-alive",
+                "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
+            },
+        },
+        {
+            "event": "response",
+            "headers": {"server": "testserver"},
+        },
+    ]
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_async_event_hooks_with_redirect():
+    """
+    A redirect request should not trigger a second 'request' event hook.
+    """
+
+    events = []
+
+    async def on_request(request):
+        events.append({"event": "request", "headers": dict(request.headers)})
+
+    async def on_response(response):
+        events.append({"event": "response", "headers": dict(response.headers)})
+
+    event_hooks = {"request": [on_request], "response": [on_response]}
+
+    async with httpx.AsyncClient(
+        event_hooks=event_hooks, transport=AsyncMockTransport(app)
+    ) as http:
+        await http.get("http://127.0.0.1:8000/redirect", auth=("username", "password"))
+
+    assert events == [
+        {
+            "event": "request",
+            "headers": {
+                "host": "127.0.0.1:8000",
+                "user-agent": f"python-httpx/{httpx.__version__}",
+                "accept": "*/*",
+                "accept-encoding": "gzip, deflate, br",
+                "connection": "keep-alive",
+                "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
+            },
+        },
+        {
+            "event": "response",
+            "headers": {"server": "testserver"},
+        },
+    ]
index cb6ee3285167e2076e742999a330426387b0f2dc..bf7399491908126466f366f48309411af301ffa3 100644 (file)
@@ -49,3 +49,12 @@ def test_client_timeout():
     assert client.timeout.read == expected_timeout
     assert client.timeout.write == expected_timeout
     assert client.timeout.pool == expected_timeout
+
+
+def test_client_event_hooks():
+    def on_request(request):
+        pass  # pragma: nocover
+
+    client = httpx.Client()
+    client.event_hooks = {"request": [on_request]}
+    assert client.event_hooks == {"request": [on_request], "response": []}