Skip to content

Event hooks #1246

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Sep 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f07bfc5
Add EventHooks internal datastructure
tomchristie Aug 24, 2020
3767797
Add support for 'request' and 'response' event hooks
tomchristie Aug 24, 2020
4450ea2
Support Client.event_hooks property
tomchristie Aug 24, 2020
d3e0472
Handle exceptions raised by response event hooks
tomchristie Aug 24, 2020
7247802
Docs for event hooks
tomchristie Aug 24, 2020
e9ca1c1
Only support 'request' and 'response' event hooks
tomchristie Aug 24, 2020
b61972c
Add event_hooks to top-level API
tomchristie Aug 24, 2020
3da00da
Event hooks
tomchristie Sep 2, 2020
99a063e
Merge master
tomchristie Sep 2, 2020
e8e7419
Merge branch 'master' into event-hooks-revisited
tomchristie Sep 2, 2020
92b8739
Formatting
tomchristie Sep 2, 2020
d2fba5a
Formatting
tomchristie Sep 2, 2020
2d09ad1
Merge branch 'master' into event-hooks-revisited
tomchristie Sep 2, 2020
2ee0745
Merge branch 'master' into event-hooks-revisited
tomchristie Sep 3, 2020
d0d8ddf
Fix up event hooks test
tomchristie Sep 3, 2020
2e62c97
Add test case to confirm that redirects/event hooks don't currently p…
tomchristie Sep 7, 2020
89ab7c9
Merge master
tomchristie Sep 10, 2020
486d437
Merge master
tomchristie Sep 14, 2020
545c058
Refactor test cases
tomchristie Sep 14, 2020
da292a5
Make response.request clear in response event hooks docs
tomchristie Sep 14, 2020
eac61ff
Drop merge marker
tomchristie Sep 14, 2020
c8ce977
Request event hook runs as soon as we have an auth-constructed request
tomchristie Sep 14, 2020
da71106
Merge branch 'master' into event-hooks-revisited
tomchristie Sep 15, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions httpx/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,12 @@ def __init__(
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)
Expand All @@ -85,6 +88,10 @@ def __init__(
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
Expand Down Expand Up @@ -133,6 +140,19 @@ def timeout(self) -> Timeout:
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]:
"""
Expand Down Expand Up @@ -532,6 +552,7 @@ def __init__(
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,
Expand All @@ -544,6 +565,7 @@ def __init__(
cookies=cookies,
timeout=timeout,
max_redirects=max_redirects,
event_hooks=event_hooks,
base_url=base_url,
trust_env=trust_env,
)
Expand Down Expand Up @@ -739,6 +761,13 @@ def send(
finally:
response.close()

try:
for hook in self._event_hooks["response"]:
hook(response)
except Exception:
response.close()
raise

return response

def _send_handling_auth(
Expand All @@ -752,6 +781,9 @@ def _send_handling_auth(
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,
Expand Down Expand Up @@ -1153,6 +1185,7 @@ def __init__(
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,
Expand All @@ -1165,6 +1198,7 @@ def __init__(
cookies=cookies,
timeout=timeout,
max_redirects=max_redirects,
event_hooks=event_hooks,
base_url=base_url,
trust_env=trust_env,
)
Expand Down Expand Up @@ -1362,6 +1396,13 @@ async def send(
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(
Expand All @@ -1375,6 +1416,9 @@ async def _send_handling_auth(
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,
Expand Down
189 changes: 189 additions & 0 deletions tests/client/test_event_hooks.py
Original file line number Diff line number Diff line change
@@ -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"},
},
]
9 changes: 9 additions & 0 deletions tests/client/test_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": []}