-
-
Notifications
You must be signed in to change notification settings - Fork 857
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
Graceful handling of 204 responses that incorrectly a include non-zero Content-Length. #1474
Comments
Hello @aviramha, that's a pretty interesting one, thanks. I was able to reproduce against this script: App: async def app(scope, receive, send):
await send(
{
"type": "http.response.start",
"status": 204,
"headers": [(b"content-length", b"4")],
}
)
await send({"type": "http.response.body", "body": b"Hey!"}) Client: import httpx
client = httpx.Client()
response = client.get("http://localhost:8000")
assert response.status_code == 204
client.get("http://localhost:8000") Trace output: TRACE [2021-02-18 16:49:18] httpx._config - load_ssl_context verify=True cert=None trust_env=True http2=False
TRACE [2021-02-18 16:49:18] httpx._config - load_verify_locations cafile=/Users/florimond.manca/Developer/encode-projects/httpx/venv/lib/python3.8/site-packages/certifi/cacert.pem
TRACE [2021-02-18 16:49:18] httpcore._sync.connection_pool - get_connection_from_pool=(b'http', b'localhost', 8000)
TRACE [2021-02-18 16:49:18] httpcore._sync.connection_pool - created connection=<SyncHTTPConnection http_version=UNKNOWN state=0>
TRACE [2021-02-18 16:49:18] httpcore._sync.connection_pool - adding connection to pool=<SyncHTTPConnection http_version=UNKNOWN state=0>
TRACE [2021-02-18 16:49:18] httpcore._sync.connection - open_socket origin=(b'http', b'localhost', 8000) timeout={'connect': 5.0, 'read': 5.0, 'write': 5.0, 'pool': 5.0}
TRACE [2021-02-18 16:49:18] httpcore._sync.connection - create_connection socket=<httpcore._backends.sync.SyncSocketStream object at 0x102f60040> http_version='HTTP/1.1'
TRACE [2021-02-18 16:49:18] httpcore._sync.connection - connection.request method=b'GET' url=(b'http', b'localhost', 8000, b'/') headers=[(b'Host', b'localhost:8000'), (b'Accept', b'*/*'), (b'Accept-Encoding', b'gzip, deflate, br'), (b'Connection', b'keep-alive'), (b'User-Agent', b'python-httpx/0.16.1')]
TRACE [2021-02-18 16:49:18] httpcore._sync.http11 - send_request method=b'GET' url=(b'http', b'localhost', 8000, b'/') headers=[(b'Host', b'localhost:8000'), (b'Accept', b'*/*'), (b'Accept-Encoding', b'gzip, deflate, br'), (b'Connection', b'keep-alive'), (b'User-Agent', b'python-httpx/0.16.1')]
TRACE [2021-02-18 16:49:18] httpcore._sync.http11 - send_data=Data(<0 bytes>)
DEBUG [2021-02-18 16:49:18] httpx._client - HTTP Request: GET http://localhost:8000 "HTTP/1.1 204 No Content"
TRACE [2021-02-18 16:49:18] httpcore._sync.http11 - receive_event=EndOfMessage(headers=[])
TRACE [2021-02-18 16:49:18] httpcore._sync.http11 - response_closed our_state=DONE their_state=DONE
TRACE [2021-02-18 16:49:18] httpcore._sync.connection_pool - get_connection_from_pool=(b'http', b'localhost', 8000)
TRACE [2021-02-18 16:49:18] httpcore._sync.connection_pool - reusing idle http11 connection=<SyncHTTPConnection http_version=HTTP/1.1 state=4>
TRACE [2021-02-18 16:49:18] httpcore._sync.connection_pool - reuse connection=<SyncHTTPConnection http_version=HTTP/1.1 state=1>
TRACE [2021-02-18 16:49:18] httpcore._sync.connection - connection.request method=b'GET' url=(b'http', b'localhost', 8000, b'/') headers=[(b'Host', b'localhost:8000'), (b'Accept', b'*/*'), (b'Accept-Encoding', b'gzip, deflate, br'), (b'Connection', b'keep-alive'), (b'User-Agent', b'python-httpx/0.16.1')]
TRACE [2021-02-18 16:49:18] httpcore._sync.http11 - send_request method=b'GET' url=(b'http', b'localhost', 8000, b'/') headers=[(b'Host', b'localhost:8000'), (b'Accept', b'*/*'), (b'Accept-Encoding', b'gzip, deflate, br'), (b'Connection', b'keep-alive'), (b'User-Agent', b'python-httpx/0.16.1')]
TRACE [2021-02-18 16:49:18] httpcore._sync.http11 - send_data=Data(<0 bytes>)
TRACE [2021-02-18 16:49:18] httpcore._sync.connection_pool - remove from pool connection=<SyncHTTPConnection http_version=HTTP/1.1 state=2>
TRACE [2021-02-18 16:49:18] httpcore._sync.connection_pool - removing connection from pool=<SyncHTTPConnection http_version=HTTP/1.1 state=2> This does NOT reproduce if:
The exception comes from a call to The fact that it only occurs upon 2nd request makes me think that, despite calling But that's only a supposition. It's not clear to me whether we have a bug, or What could help would be to simulate sending the request through |
Okay. So, not super surprising that
And yes, In terms of ecosystem here, what would be neater would be if either the server or the framework was indicating an error. (More likely the server, since the framework probably? shouldn't have that level of detail about the HTTP semantics built-in.) Having said that, it's feasible that there's scope for more lenient "parse it if you can at all" for various edge cases in |
Having switched from bug to u-x, it's actually slightly less clear to me, as it'd be preferable if the first request was the point at which this is handled. Perhaps there are gracefulnesses here such as force-closing the connection on responses that include invalid content bodies. |
I did some digging and also came with the h11 strictness. I opened a PR to fastapi to overcome this but maybe this should also be enforced via starlette? |
Well, I'd probably suggest at the server level, rather than each individual framework. So perhaps in Uvicorn. It'd be interesting to know how Gunicorn handles this case. |
I wouldn't put my trust into Gunicorn as it's very lightweight and permissive. I did the test anyway which showed it allows the same issue to occur: def app(environ, start_response):
"""Simplest possible application object"""
data = b'Hello, World!\n'
status = '204 No Content'
response_headers = [
('Content-type', 'text/plain'),
('Content-Length', str(len(data)))
]
start_response(status, response_headers)
return iter([data]) curl output:
|
Enforced by # Step 1: some responses always have an empty body, regardless of what the
# headers say.
if type(event) is Response:
if (
event.status_code in (204, 304)
or request_method == b"HEAD"
or (request_method == b"CONNECT" and 200 <= event.status_code < 300)
):
return ("content-length", (0,)) Would be useful to know if One sensible tactic here might be to close the connection if we get a |
Bumped into this issue too. The part of the first response's body that was not fully read is remaining in the underlying buffer. The second response reuses that buffer and hence is considered malformed.
In this ^ particular case the previous response was 204, but its body had |
That would not be my conclusion - it looks very much like a valid behaviour in response to malformed server behaviour. |
@tomchristie thank you for your reply. let me share some more reasoning:
|
meanwhile a pretty-bad looking workaround: resp = self._client.patch(...)
resp.read()
# TODO: remove once https://github.com/encode/httpx/issues/1474 is
# fixed
resp.stream._stream._httpcore_stream.connection.close() |
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. |
Still valid thx, @Stale bot. |
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. |
As far as the original example code is concerned here, FastAPI is at fault here (or at least, users returning |
Yep, FastAPI was at fault. This was solved in fastapi/fastapi#5145, available since FastAPI |
Checklist
master
.Describe the bug
HTTPX Session encounters parsing error (RemoteProtocolError: malformed data) when an endpoint returns content when status code is 204 (HTTP No Content). Other Python http clients handle this scenario.. I'm not sure if httpx should actually handle this case, since this is a weird case, but maybe it's best to handle it?
To reproduce
setup FastAPI server with this code:
Run it using
uvicorn
. In another terminal run the following snippet:Expected behavior
No errors?
Actual behavior
malformed data
Debugging material
Environment
The text was updated successfully, but these errors were encountered: