Event hooks (#1246)
* 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
This commit is contained in:
parent
d0fe113945
commit
54f7708e2b
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
189
tests/client/test_event_hooks.py
Normal file
189
tests/client/test_event_hooks.py
Normal file
@ -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"},
|
||||
},
|
||||
]
|
||||
@ -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": []}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user