feat: handle response for different reasons

- Add StreamCloseReason enum
- Send response for different cases considering download_warnsize,
download_maxsize, fail_on_data_loss, connection lost, etc.
This commit is contained in:
Aditya 2020-06-21 09:34:23 +05:30
parent 303485a9b4
commit c74ef660c7
2 changed files with 155 additions and 69 deletions

View File

@ -11,7 +11,7 @@ from h2.events import (
)
from twisted.internet.protocol import connectionDone, Protocol
from scrapy.core.http2.stream import Stream
from scrapy.core.http2.stream import Stream, StreamCloseReason
from scrapy.http import Request
LOGGER = logging.getLogger(__name__)
@ -71,7 +71,7 @@ class H2ClientProtocol(Protocol):
stream_id=stream_id,
request=request,
connection=self.conn,
metadata=self._metadata,
conn_metadata=self._metadata,
write_to_transport=self._write_to_transport,
cb_close=self._stream_close_cb
)
@ -133,7 +133,7 @@ class H2ClientProtocol(Protocol):
"""
# Pop all streams which were pending and were not yet started
for stream_id in list(self.streams):
self.streams[stream_id].close()
self.streams[stream_id].close(StreamCloseReason.CONNECTION_LOST)
self.conn.close_connection()
@ -180,19 +180,18 @@ class H2ClientProtocol(Protocol):
def stream_ended(self, event: StreamEnded):
stream_id = event.stream_id
self.streams[stream_id].close()
self.streams[stream_id].close(StreamCloseReason.ENDED)
def stream_reset(self, event: StreamReset):
# TODO: event.stream_id was abruptly closed
# Q. What should be the response? (Failure/Partial/???)
self.streams[event.stream_id].close(event)
self.streams[event.stream_id].close(StreamCloseReason.RESET)
def window_updated(self, event: WindowUpdated):
stream_id = event.stream_id
if stream_id != 0:
self.streams[stream_id].receive_window_update(event.delta)
self.streams[stream_id].receive_window_update()
else:
# Send leftover data for all the streams
for stream in self.streams.values():
if stream.request_sent:
stream.send_data()
stream.receive_window_update()

View File

@ -1,11 +1,15 @@
import logging
from enum import IntFlag, auto
from io import BytesIO
from typing import Dict
from urllib.parse import urlparse
from h2.connection import H2Connection
from h2.events import StreamEnded
from h2.errors import ErrorCodes
from h2.exceptions import StreamClosedError
from twisted.internet.defer import Deferred
from twisted.internet.defer import Deferred, CancelledError
from twisted.python.failure import Failure
from twisted.web.client import ResponseFailed
from scrapy.http import Request
from scrapy.http.headers import Headers
@ -14,6 +18,20 @@ from scrapy.responsetypes import responsetypes
LOGGER = logging.getLogger(__name__)
class StreamCloseReason(IntFlag):
# Received a StreamEnded event
ENDED = auto()
# Received a StreamReset event -- ended abruptly
RESET = auto()
# Transport connection was lost
CONNECTION_LOST = auto()
# Expected response body size is more than allowed limit
MAXSIZE_EXCEEDED = auto()
class Stream:
"""Represents a single HTTP/2 Stream.
@ -26,13 +44,16 @@ class Stream:
"""
def __init__(
self,
stream_id: int,
request: Request,
connection: H2Connection,
metadata: Dict,
write_to_transport,
cb_close
self,
stream_id: int,
request: Request,
connection: H2Connection,
conn_metadata: Dict,
write_to_transport,
cb_close,
download_maxsize=0,
download_warnsize=0,
fail_on_data_loss=True
):
"""
Arguments:
@ -40,7 +61,7 @@ class Stream:
uniquely identified by a single integer
request {Request} -- HTTP request
connection {H2Connection} -- HTTP/2 connection this stream belongs to.
metadata {Dict} -- Reference to dictionary having metadata of HTTP/2 connection
conn_metadata {Dict} -- Reference to dictionary having metadata of HTTP/2 connection
write_to_transport {callable} -- Method used to write & send data to the server
This method should be used whenever some frame is to be sent to the server.
cb_close {callable} -- Method called when this stream is closed
@ -49,16 +70,24 @@ class Stream:
self.stream_id = stream_id
self._request = request
self._conn = connection
self._metadata = metadata
self._conn_metadata = conn_metadata
self._write_to_transport = write_to_transport
self._cb_close = cb_close
self._request_body = self._request.body
self.content_length = 0 if self._request_body is None else len(self._request_body)
self._download_maxsize = self._request.meta.get('download_maxsize', download_maxsize)
self._download_warnsize = self._request.meta.get('download_warnsize', download_warnsize)
self._fail_on_dataloss = self._request.meta.get('download_fail_on_dataloss', fail_on_data_loss)
self.request_start_time = None
self.content_length = 0 if self._request.body is None else len(self._request.body)
# Flag to keep track whether this stream has initiated the request
self.request_sent = False
# Flag to track whether we have logged about exceeding download warnsize
self._reached_warnsize = False
# Each time we send a data frame, we will decrease value by the amount send.
self.remaining_content_length = self.content_length
@ -68,17 +97,17 @@ class Stream:
# Flag to keep track whether the server has closed the stream
self.stream_closed_server = False
# The amount of data received that counts against the flow control
# window
self._response_flow_controlled_size = 0
# Private variable used to build the response
# this response is then converted to appropriate Response class
# passed to the response deferred callback
self._response = {
# Data received frame by frame from the server is appended
# and passed to the response Deferred when completely received.
'body': b'',
'body': BytesIO(),
# The amount of data received that counts against the flow control
# window
'flow_controlled_size': 0,
# Headers received after sending the request
'headers': Headers({})
@ -137,16 +166,8 @@ class Stream:
Warning: Only call this method when stream not closed from client side
and has initiated request already by sending HEADER frame. If not then
stream will be closed from client side with 499 response.
TODO: Q. Should we instead raise ProtocolError here with a proper message?
stream will raise ProtocolError (raise by h2 state machine).
"""
if self.stream_closed_local or self.stream_closed_server:
raise StreamClosedError(self.stream_id)
elif not self.request_sent:
self.close()
return
# TODO:
# 1. Add test for sending very large data
# 2. Add test for small data
@ -154,6 +175,9 @@ class Stream:
# 3.1 Large number of request
# 3.2 Small number of requests
if self.stream_closed_local:
raise StreamClosedError(self.stream_id)
# Firstly, check what the flow control window is for current stream.
window_size = self._conn.local_flow_control_window(stream_id=self.stream_id)
@ -170,7 +194,7 @@ class Stream:
chunk_size = min(bytes_to_send_size, max_frame_size)
data_chunk_start_id = self.content_length - self.remaining_content_length
data_chunk = self._request_body[data_chunk_start_id:data_chunk_start_id + chunk_size]
data_chunk = self._request.body[data_chunk_start_id:data_chunk_start_id + chunk_size]
self._conn.send_data(self.stream_id, data_chunk, end_stream=False)
@ -183,7 +207,7 @@ class Stream:
self,
self.content_length - self.remaining_content_length, self.content_length,
data_frames_sent,
self._metadata['ip_address'])
self._conn_metadata['ip_address'])
)
# End the stream if no more data needs to be send
@ -197,24 +221,40 @@ class Stream:
# Q. What about the rest of the data?
# Ans: Remaining Data frames will be sent when we get a WindowUpdate frame
def receive_window_update(self, delta):
def receive_window_update(self):
"""Flow control window size was changed.
Send data that earlier could not be sent as we were
blocked behind the flow control.
Arguments:
delta -- Window change delta
"""
if self.remaining_content_length > 0 and not self.stream_closed_server:
if self.remaining_content_length and not self.stream_closed_server and self.request_sent:
self.send_data()
def receive_data(self, data: bytes, flow_controlled_length: int):
self._response['body'] += data
self._response_flow_controlled_size += flow_controlled_length
self._response['body'].write(data)
self._response['flow_controlled_size'] += flow_controlled_length
if self._download_maxsize and self._response['flow_controlled_size'] > self._download_maxsize:
# Clear buffer earlier to avoid keeping data in memory for a long time
self._response['body'].truncate(0)
self.reset_stream(StreamCloseReason.MAXSIZE_EXCEEDED)
return
if self._download_warnsize \
and self._response['flow_controlled_size'] > self._download_warnsize \
and not self._reached_warnsize:
self._reached_warnsize = True
warning_msg = ('Received more ({bytes}) bytes than download ',
'warn size ({warnsize}) in request {request}')
warning_args = {
'bytes': self._response['flow_controlled_size'],
'warnsize': self._download_warnsize,
'request': self._request
}
LOGGER.warning(warning_msg, warning_args)
# Acknowledge the data received
self._conn.acknowledge_received_data(
self._response_flow_controlled_size,
self._response['flow_controlled_size'],
self.stream_id
)
@ -222,35 +262,83 @@ class Stream:
for name, value in headers:
self._response['headers'][name] = value
def close(self, event=None):
"""Based on the event sent we will handle each case.
# Check if we exceed the allowed max data size which can be received
expected_size = int(self._response['headers'].get(b'Content-Length', -1))
if self._download_maxsize and expected_size > self._download_maxsize:
self.reset_stream(StreamCloseReason.MAXSIZE_EXCEEDED)
return
event: StreamEnded
Stream is ended by the server hence no further
data or headers should be expected on this stream.
We will call the response deferred callback passing
the response object
if self._download_warnsize and expected_size > self._download_warnsize:
warning_msg = ("Expected response size ({size}) larger than ",
"download warn size ({warnsize}) in request {request}.")
warning_args = {
'size': expected_size, 'warnsize': self._download_warnsize,
'request': self._request
}
LOGGER.warning(warning_msg, warning_args)
event: StreamReset
Stream reset via RST_FRAME by the upstream hence forcefully close
this stream and send TODO: ?
def reset_stream(self, reason=StreamCloseReason.RESET):
"""Close this stream by sending a RST_FRAME to the remote peer"""
# TODO: Q. REFUSED_STREAM or CANCEL ?
if self.stream_closed_local:
raise StreamClosedError(self.stream_id)
event: None
No event is launched -- Hence we will simply close this stream
self.stream_closed_local = True
self._conn.reset_stream(self.stream_id, ErrorCodes.REFUSED_STREAM)
self._write_to_transport()
self.close(reason)
def _is_data_lost(self) -> bool:
assert self.stream_closed_server
expected_size = self._response['flow_controlled_size']
received_body_size = int(self._response['headers'][b'Content-Length'])
return expected_size != received_body_size
def close(self, reason: StreamCloseReason):
"""Based on the reason sent we will handle each case.
"""
# TODO: In case of abruptly stream close
# Q1. Do we need to send the request again?
# Q2. What response should we send now?
assert self.stream_closed_server is False
if self.stream_closed_server:
raise StreamClosedError(self.stream_id)
self.stream_closed_server = True
if not isinstance(event, StreamEnded):
# TODO
# Stream was abruptly ended here
# Partial - Content-Length header not provided
pass
flags = None
if b'Content-Length' not in self._response['headers']:
# Missing Content-Length - PotentialDataLoss
flags = ['partial']
elif self._is_data_lost():
if self._fail_on_dataloss:
self._deferred_response.errback(ResponseFailed([Failure()]))
self._cb_close(self.stream_id)
return
else:
flags = ['dataloss']
if reason is StreamCloseReason.ENDED:
self._fire_response_deferred(flags)
elif reason in (StreamCloseReason.RESET | StreamCloseReason.CONNECTION_LOST):
# Stream was abruptly ended here
self._deferred_response.errback(ResponseFailed([Failure()]))
elif reason is StreamCloseReason.MAXSIZE_EXCEEDED:
expected_size = int(self._response['headers'].get(b'Content-Length', -1))
error_msg = ("Cancelling download of {url}: expected response "
"size ({size}) larger than download max size ({maxsize}).")
error_args = {
'url': self._request.url,
'size': expected_size,
'maxsize': self._download_maxsize
}
LOGGER.error(error_msg, error_args)
self._deferred_response.errback(CancelledError(error_msg.format(**error_args)))
self._fire_response_deferred()
self._cb_close(self.stream_id)
def _fire_response_deferred(self, flags=None):
@ -258,7 +346,6 @@ class Stream:
and fires the response deferred callback with the
generated response instance"""
# TODO:
# 1. Set flags, certificate, ip_address in response
# 2. Should we fire this in case of
# 2.1 StreamReset in between when data is received partially
# 2.2 Forcefully closed the stream
@ -270,7 +357,7 @@ class Stream:
body=self._response['body']
)
# If there is :status in headers then
# If there is no :status in headers then
# HTTP Status Code: 499 - Client Closed Request
status = self._response['headers'].get(':status', '499')
@ -278,11 +365,11 @@ class Stream:
url=self._request.url,
status=status,
headers=self._response['headers'],
body=self._response['body'],
body=self._response['body'].getvalue(),
request=self._request,
flags=flags,
certificate=self._metadata['certificate'],
ip_address=self._metadata['ip_address']
certificate=self._conn_metadata['certificate'],
ip_address=self._conn_metadata['ip_address']
)
self._deferred_response.callback(response)