Repository: abhinavsingh/proxy.py

Issue:
[build_response] Empty origin responses trigger an AssertionError in build_response
**Check FAQs**
Please check [Frequently Asked Questions](https://github.com/abhinavsingh/proxy.py#frequently-asked-questions) before opening a bug report.

**Describe the bug**
I'm building a plugin that has to modify the headers/body in both the request and response, and am using the built-in `proxy.plugin.modify_chunk_response` plugin as a template for doing so.

When calling an endpoint which returns a zero-length response, proxy.py throws an AssertionError. This appears to be due to `build_response` used in the example, which includes `self.body` in the assertion check. An origin could return a zero-length body, which would cause this assertion to fail.

**To Reproduce**
1. Modify the behavior in modify_chunk_response.py to not update the body, i.e. comment this line out: https://github.com/abhinavsingh/proxy.py/blob/525794b09652201d4058f9f809be41402a2c40e9/proxy/plugin/modify_chunk_response.py#L46

2. Run proxy.py with standard HTTP proxy settings, loading the aforementioned plugin:
    ```
    2021-06-01 16:25:19,838 - pid:15164 [I] load_plugins:334 - Loaded plugin proxy.http.proxy.HttpProxyPlugin
    2021-06-01 16:25:19,838 - pid:15164 [I] load_plugins:334 - Loaded plugin proxy.plugin.ModifyChunkResponsePlugin
    2021-06-01 16:25:19,839 - pid:15164 [I] listen:115 - Listening on 127.0.0.1:8899
    2021-06-01 16:25:19,842 - pid:15164 [D] start_workers:133 - Started acceptor#0 process 15196
    2021-06-01 16:25:19,845 - pid:15164 [D] start_workers:133 - Started acceptor#1 process 15197
    2021-06-01 16:25:19,848 - pid:15164 [D] start_workers:133 - Started acceptor#2 process 15198
    2021-06-01 16:25:19,851 - pid:15164 [D] start_workers:133 - Started acceptor#3 process 15199
    2021-06-01 16:25:19,851 - pid:15164 [I] start_workers:136 - Started 4 workers
    ```

3. Call an HTTP endpoint that returns no content in the response (e.g. a `204 No Content` response):
    ```
    $ curl --proxy localhost:9988 "http://httpbin.org/status/204" -H "accept: text/plain" -vvv
    *   Trying 127.0.0.1...
    * TCP_NODELAY set
    * Connected to localhost (127.0.0.1) port 8899 (#0)
    > GET http://httpbin.org/status/204 HTTP/1.1
    > Host: httpbin.org
    > User-Agent: curl/7.58.0
    > Proxy-Connection: Keep-Alive
    > accept: text/plain
    > 
    * Empty reply from server
    * Connection #0 to host localhost left intact
    curl: (52) Empty reply from server
    ```

4. Observe proxy.py's log output.
    ```
    2021-06-01 16:45:54,169 - pid:23651 [D] initialize:104 - Handling connection <socket.socket fd=18, family=AddressFamily.AF_INET, type=2049, proto=0, laddr=('127.0.0.1', 8899), raddr=('127.0.0.1', 47158)>
    2021-06-01 16:45:54,178 - pid:23651 [D] handle_readables:251 - Client is ready for reads, reading
    2021-06-01 16:45:54,178 - pid:23651 [D] recv:65 - received 140 bytes from client
    2021-06-01 16:45:54,179 - pid:23651 [D] connect_upstream:417 - Connecting to upstream httpbin.org:80
    2021-06-01 16:45:54,259 - pid:23651 [D] connect_upstream:422 - Connected to upstream httpbin.org:80
    2021-06-01 16:45:54,259 - pid:23651 [D] write_to_descriptors:157 - Server is write ready, flushing buffer
    2021-06-01 16:45:54,259 - pid:23651 [D] flush:92 - flushed 118 bytes to server
    2021-06-01 16:45:54,328 - pid:23651 [D] read_from_descriptors:179 - Server is ready for reads, reading...
    2021-06-01 16:45:54,328 - pid:23651 [D] recv:65 - received 225 bytes from server
    2021-06-01 16:45:59,214 - pid:23651 [E] run:363 - Exception while handling connection <socket.socket fd=18, family=AddressFamily.AF_INET, type=2049, proto=0, laddr=('127.0.0.1', 8899), raddr=('127.0.0.1', 47158)>
    Traceback (most recent call last):
      File "[snip]/proxy/http/handler.py", line 353, in run
        teardown = self.run_once()
      File "[snip]/proxy/http/handler.py", line 338, in run_once
        teardown = self.handle_events(readables, writables)
      File "[snip]/proxy/http/handler.py", line 158, in handle_events
        teardown = plugin.read_from_descriptors(readables)
      File "[snip]/proxy/http/proxy/server.py", line 212, in read_from_descriptors
        raw = plugin.handle_upstream_chunk(raw)
      File "[snip]/proxy/plugin/modify_chunk_response.py", line 47, in handle_upstream_chunk
        self.client.queue(memoryview(self.response.build_response()))
      File "[snip]/proxy/http/parser.py", line 257, in build_response
        assert self.code and self.version and self.body and self.type == httpParserTypes.RESPONSE_PARSER
    AssertionError
    ```

**Expected behavior**
A zero-length response is returned to the client.

**Version information**
 - OS: [e.g. iOS] Ubuntu Linux v. 18.04.5
 - Browser [e.g. chrome, safari] cURL 7.58.0
 - Device: [e.g. iPhone6] ...laptop?
 - proxy.py Version [e.g. 1.1.1] 2.3.1

**Additional context**
This should be a relatively easy change to this line: https://github.com/abhinavsingh/proxy.py/blob/d977fc71e8b02bfd26283ce2ccf8bee77ade16fa/proxy/http/parser.py#L257

Don't validate that there's a body, as `build_http_response()` already has a check for a zero-length body.
Alternatively, if plugin devs should be using `build_http_response()` directly, perhaps the example plugin should be updated.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) Report
> Merging [#688](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) (a165a6b) into [develop](https://codecov.io/gh/abhinavsingh/proxy.py/commit/9e9ca90087243cfccc1de3fb5f29b1fb8371d748?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) (9e9ca90) will **decrease** coverage by `0.11%`.
> The diff coverage is `50.00%`.

[![Impacted file tree graph](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688/graphs/tree.svg?width=650&height=150&src=pr&token=Zh9J7b4la2&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh)](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh)

```diff
@@             Coverage Diff             @@
##           develop     #688      +/-   ##
===========================================
- Coverage    86.13%   86.01%   -0.12%     
===========================================
  Files          106      106              
  Lines         4768     4771       +3     
  Branches       390      391       +1     
===========================================
- Hits          4107     4104       -3     
- Misses         590      593       +3     
- Partials        71       74       +3     
```

| Flag | Coverage Δ | |
|---|---|---|
| pytest | `85.95% <50.00%> (-0.06%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) | Coverage Δ | |
|---|---|---|
| [proxy/plugin/modify\_chunk\_response.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvcGx1Z2luL21vZGlmeV9jaHVua19yZXNwb25zZS5weQ==) | `50.00% <0.00%> (-2.39%)` | :arrow_down: |
| [proxy/http/parser.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvaHR0cC9wYXJzZXIucHk=) | `94.54% <75.00%> (+0.06%)` | :arrow_up: |
| [tests/test\_set\_open\_file\_limit.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-dGVzdHMvdGVzdF9zZXRfb3Blbl9maWxlX2xpbWl0LnB5) | `96.29% <0.00%> (-3.71%)` | :arrow_down: |
| [tests/testing/test\_embed.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-dGVzdHMvdGVzdGluZy90ZXN0X2VtYmVkLnB5) | `91.17% <0.00%> (-2.95%)` | :arrow_down: |
| [proxy/proxy.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvcHJveHkucHk=) | `95.21% <0.00%> (-0.96%)` | :arrow_down: |
| [tests/test\_main.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-dGVzdHMvdGVzdF9tYWluLnB5) | `99.47% <0.00%> (-0.53%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh). Last update [9e9ca90...a165a6b](https://codecov.io/gh/abhinavsingh/proxy.py/pull/688?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh).


Patch:
diff --git a/proxy/http/parser.py b/proxy/http/parser.py
index 927ca44..f51ec8e 100644
--- a/proxy/http/parser.py
+++ b/proxy/http/parser.py
@@ -43,35 +43,59 @@ T = TypeVar('T', bound='HttpParser')
 
 
 class HttpParser:
-    """HTTP request/response parser."""
+    """HTTP request/response parser.
+
+    TODO: Make me zero-copy by using memoryview.
+    Currently due to chunk/buffer handling we
+    are not able to utilize memoryview
+    efficiently.
+
+    For this to happen we must store `buffer`
+    as List[memoryview] instead of raw bytes and
+    update parser to work accordingly.
+    """
 
     def __init__(self, parser_type: int) -> None:
         self.type: int = parser_type
         self.state: int = httpParserStates.INITIALIZED
 
+        self.host: Optional[bytes] = None
+        self.port: Optional[int] = None
+        self.path: Optional[bytes] = None
+        self.method: Optional[bytes] = None
+        self.code: Optional[bytes] = None
+        self.reason: Optional[bytes] = None
+        self.version: Optional[bytes] = None
+
         # Total size of raw bytes passed for parsing
         self.total_size: int = 0
 
         # Buffer to hold unprocessed bytes
         self.buffer: bytes = b''
 
+        # Keys are lower case header names
+        # Values are 2-tuple containing original
+        # header and it's value as received.
         self.headers: Dict[bytes, Tuple[bytes, bytes]] = {}
         self.body: Optional[bytes] = None
 
-        self.method: Optional[bytes] = None
-        self.url: Optional[urlparse.SplitResultBytes] = None
-        self.code: Optional[bytes] = None
-        self.reason: Optional[bytes] = None
-        self.version: Optional[bytes] = None
-
         self.chunk_parser: Optional[ChunkParser] = None
 
-        # This cleans up developer APIs as Python urlparse.urlsplit behaves differently
-        # for incoming proxy request and incoming web request.  Web request is the one
-        # which is broken.
-        self.host: Optional[bytes] = None
-        self.port: Optional[int] = None
-        self.path: Optional[bytes] = None
+        # TODO: Deprecate me, we don't need this in core.
+        #
+        # Deprecated since v2.4.0
+        #
+        # This is mostly for developers so that they can directly
+        # utilize a url object, but is unnecessary as parser
+        # provides all the necessary parsed information.
+        #
+        # But developers can utilize urlsplit or whatever
+        # library they are using when necessary. This will certainly
+        # give some performance boost as url parsing won't be needed
+        # for every request/response object.
+        #
+        # (except query string and fragments)
+        self._url: Optional[urlparse.SplitResultBytes] = None
 
     @classmethod
     def request(cls: Type[T], raw: bytes) -> T:
@@ -116,157 +140,51 @@ class HttpParser:
         # with urlsplit, which expects a fully qualified url.
         if self.method == httpMethods.CONNECT:
             url = b'https://' + url
-        self.url = urlparse.urlsplit(url)
-        self.set_line_attributes()
-
-    def set_line_attributes(self) -> None:
-        if self.type == httpParserTypes.REQUEST_PARSER:
-            if self.method == httpMethods.CONNECT and self.url:
-                self.host = self.url.hostname
-                self.port = 443 if self.url.port is None else self.url.port
-            elif self.url:
-                self.host, self.port = self.url.hostname, self.url.port \
-                    if self.url.port else DEFAULT_HTTP_PORT
-            else:
-                raise KeyError(
-                    'Invalid request. Method: %r, Url: %r' %
-                    (self.method, self.url),
-                )
-            self.path = self.build_path()
+        self._url = urlparse.urlsplit(url)
+        self._set_line_attributes()
 
     def is_chunked_encoded(self) -> bool:
         return b'transfer-encoding' in self.headers and \
                self.headers[b'transfer-encoding'][1].lower() == b'chunked'
 
+    def content_expected(self) -> bool:
+        return b'content-length' in self.headers and int(self.header(b'content-length')) > 0
+
     def body_expected(self) -> bool:
-        return (
-            b'content-length' in self.headers and
-            int(self.header(b'content-length')) > 0
-        ) or \
-            self.is_chunked_encoded()
+        return self.content_expected() or self.is_chunked_encoded()
 
     def parse(self, raw: bytes) -> None:
         """Parses Http request out of raw bytes.
 
-        Check HttpParser state after parse has successfully returned."""
+        Check for `HttpParser.state` after `parse` has successfully returned.
+        """
         self.total_size += len(raw)
         raw = self.buffer + raw
-        self.buffer = b''
-
-        more = len(raw) > 0
+        self.buffer, more = b'', len(raw) > 0
         while more and self.state != httpParserStates.COMPLETE:
-            if self.state in (
-                    httpParserStates.HEADERS_COMPLETE,
-                    httpParserStates.RCVING_BODY,
-            ):
-                if b'content-length' in self.headers:
-                    self.state = httpParserStates.RCVING_BODY
-                    if self.body is None:
-                        self.body = b''
-                    total_size = int(self.header(b'content-length'))
-                    received_size = len(self.body)
-                    self.body += raw[:total_size - received_size]
-                    if self.body and \
-                            len(self.body) == int(self.header(b'content-length')):
-                        self.state = httpParserStates.COMPLETE
-                    more, raw = len(raw) > 0, raw[total_size - received_size:]
-                elif self.is_chunked_encoded():
-                    if not self.chunk_parser:
-                        self.chunk_parser = ChunkParser()
-                    raw = self.chunk_parser.parse(raw)
-                    if self.chunk_parser.state == chunkParserStates.COMPLETE:
-                        self.body = self.chunk_parser.body
-                        self.state = httpParserStates.COMPLETE
-                    more = False
-                else:
-                    raise NotImplementedError(
-                        'Parser shouldn\'t have reached here. ' +
-                        'This can happen when content length header is missing but their is a body in the payload',
-                    )
-            else:
-                more, raw = self.process(raw)
+            # gte with HEADERS_COMPLETE also encapsulated RCVING_BODY state
+            more, raw = self._process_body(raw) \
+                if self.state >= httpParserStates.HEADERS_COMPLETE else \
+                self._process_line_and_headers(raw)
         self.buffer = raw
 
-    def process(self, raw: bytes) -> Tuple[bool, bytes]:
-        """Returns False when no CRLF could be found in received bytes."""
-        line, raw = find_http_line(raw)
-        if line is None:
-            return False, raw
-
-        if self.state == httpParserStates.INITIALIZED:
-            self.process_line(line)
-            self.state = httpParserStates.LINE_RCVD
-        elif self.state in (httpParserStates.LINE_RCVD, httpParserStates.RCVING_HEADERS):
-            if self.state == httpParserStates.LINE_RCVD:
-                # LINE_RCVD state is equivalent to RCVING_HEADERS
-                self.state = httpParserStates.RCVING_HEADERS
-            if line.strip() == b'':  # Blank line received.
-                self.state = httpParserStates.HEADERS_COMPLETE
-            else:
-                self.process_header(line)
-
-        # When server sends a response line without any header or body e.g.
-        # HTTP/1.1 200 Connection established\r\n\r\n
-        if self.state == httpParserStates.LINE_RCVD and \
-                self.type == httpParserTypes.RESPONSE_PARSER and \
-                raw == CRLF:
-            self.state = httpParserStates.COMPLETE
-        elif self.state == httpParserStates.HEADERS_COMPLETE and \
-                not self.body_expected() and \
-                raw == b'':
-            self.state = httpParserStates.COMPLETE
-
-        return len(raw) > 0, raw
-
-    def process_line(self, raw: bytes) -> None:
-        line = raw.split(WHITESPACE)
-        if self.type == httpParserTypes.REQUEST_PARSER:
-            self.method = line[0].upper()
-            self.set_url(line[1])
-            self.version = line[2]
-        else:
-            self.version = line[0]
-            self.code = line[1]
-            self.reason = WHITESPACE.join(line[2:])
-
-    def process_header(self, raw: bytes) -> None:
-        parts = raw.split(COLON)
-        key = parts[0].strip()
-        value = COLON.join(parts[1:]).strip()
-        self.add_headers([(key, value)])
-
-    def build_path(self) -> bytes:
-        if not self.url:
-            return b'/None'
-        url = self.url.path
-        if url == b'':
-            url = b'/'
-        if not self.url.query == b'':
-            url += b'?' + self.url.query
-        if not self.url.fragment == b'':
-            url += b'#' + self.url.fragment
-        return url
-
     def build(self, disable_headers: Optional[List[bytes]] = None, for_proxy: bool = False) -> bytes:
         """Rebuild the request object."""
         assert self.method and self.version and self.path and self.type == httpParserTypes.REQUEST_PARSER
         if disable_headers is None:
             disable_headers = DEFAULT_DISABLE_HEADERS
-        body: Optional[bytes] = ChunkParser.to_chunks(self.body) \
-            if self.is_chunked_encoded() and self.body else \
-            self.body
+        body: Optional[bytes] = self._get_body_or_chunks()
         path = self.path
         if for_proxy:
-            assert self.url and self.host and self.port and self.path
+            assert self._url and self.host and self.port and self.path
             path = (
-                self.url.scheme +
+                self._url.scheme +
                 COLON + SLASH + SLASH +
                 self.host +
                 COLON +
                 str(self.port).encode() +
                 self.path
             ) if self.method != httpMethods.CONNECT else (self.host + COLON + str(self.port).encode())
-
         return build_http_request(
             self.method, path, self.version,
             headers={} if not self.headers else {
@@ -278,7 +196,7 @@ class HttpParser:
 
     def build_response(self) -> bytes:
         """Rebuild the response object."""
-        assert self.code and self.version and self.body and self.type == httpParserTypes.RESPONSE_PARSER
+        assert self.code and self.version and self.type == httpParserTypes.RESPONSE_PARSER
         return build_http_response(
             status_code=int(self.code),
             protocol_version=self.version,
@@ -286,8 +204,7 @@ class HttpParser:
             headers={} if not self.headers else {
                 self.headers[k][0]: self.headers[k][1] for k in self.headers
             },
-            body=self.body if not self.is_chunked_encoded(
-            ) else ChunkParser.to_chunks(self.body),
+            body=self._get_body_or_chunks(),
         )
 
     def has_host(self) -> bool:
@@ -305,3 +222,110 @@ class HttpParser:
         return self.version == HTTP_1_1 and \
             self.has_header(b'Connection') and \
             self.has_header(b'Upgrade')
+
+    def _process_body(self, raw: bytes) -> Tuple[bool, bytes]:
+        if b'content-length' in self.headers:
+            self.state = httpParserStates.RCVING_BODY
+            if self.body is None:
+                self.body = b''
+            total_size = int(self.header(b'content-length'))
+            received_size = len(self.body)
+            self.body += raw[:total_size - received_size]
+            if self.body and \
+                    len(self.body) == int(self.header(b'content-length')):
+                self.state = httpParserStates.COMPLETE
+            more, raw = len(raw) > 0, raw[total_size - received_size:]
+        elif self.is_chunked_encoded():
+            if not self.chunk_parser:
+                self.chunk_parser = ChunkParser()
+            raw = self.chunk_parser.parse(raw)
+            if self.chunk_parser.state == chunkParserStates.COMPLETE:
+                self.body = self.chunk_parser.body
+                self.state = httpParserStates.COMPLETE
+            more = False
+        else:
+            raise NotImplementedError(
+                'Parser shouldn\'t have reached here. ' +
+                'This can happen when content length header is missing but their is a body in the payload',
+            )
+        return more, raw
+
+    def _process_line_and_headers(self, raw: bytes) -> Tuple[bool, bytes]:
+        """Returns False when no CRLF could be found in received bytes."""
+        line, raw = find_http_line(raw)
+        if line is None:
+            return False, raw
+
+        if self.state == httpParserStates.INITIALIZED:
+            self._process_line(line)
+            self.state = httpParserStates.LINE_RCVD
+        elif self.state in (httpParserStates.LINE_RCVD, httpParserStates.RCVING_HEADERS):
+            if self.state == httpParserStates.LINE_RCVD:
+                # LINE_RCVD state is equivalent to RCVING_HEADERS
+                self.state = httpParserStates.RCVING_HEADERS
+            if line.strip() == b'':  # Blank line received.
+                self.state = httpParserStates.HEADERS_COMPLETE
+            else:
+                self._process_header(line)
+
+        # When server sends a response line without any header or body e.g.
+        # HTTP/1.1 200 Connection established\r\n\r\n
+        if self.state == httpParserStates.LINE_RCVD and \
+                self.type == httpParserTypes.RESPONSE_PARSER and \
+                raw == CRLF:
+            self.state = httpParserStates.COMPLETE
+        elif self.state == httpParserStates.HEADERS_COMPLETE and \
+                not self.body_expected() and \
+                raw == b'':
+            self.state = httpParserStates.COMPLETE
+
+        return len(raw) > 0, raw
+
+    def _process_line(self, raw: bytes) -> None:
+        line = raw.split(WHITESPACE)
+        if self.type == httpParserTypes.REQUEST_PARSER:
+            self.method = line[0].upper()
+            self.set_url(line[1])
+            self.version = line[2]
+        else:
+            self.version = line[0]
+            self.code = line[1]
+            self.reason = WHITESPACE.join(line[2:])
+
+    def _process_header(self, raw: bytes) -> None:
+        parts = raw.split(COLON)
+        key = parts[0].strip()
+        value = COLON.join(parts[1:]).strip()
+        self.add_headers([(key, value)])
+
+    def _get_body_or_chunks(self) -> Optional[bytes]:
+        return ChunkParser.to_chunks(self.body) \
+            if self.body and self.is_chunked_encoded() else \
+            self.body
+
+    def _set_line_attributes(self) -> None:
+        if self.type == httpParserTypes.REQUEST_PARSER:
+            if self.method == httpMethods.CONNECT and self._url:
+                self.host = self._url.hostname
+                self.port = 443 if self._url.port is None else self._url.port
+            elif self._url:
+                self.host, self.port = self._url.hostname, self._url.port \
+                    if self._url.port else DEFAULT_HTTP_PORT
+            else:
+                raise KeyError(
+                    'Invalid request. Method: %r, Url: %r' %
+                    (self.method, self._url),
+                )
+            self.path = self._build_path()
+
+    def _build_path(self) -> bytes:
+        if not self._url:
+            return b'/None'
+        url = self._url.path
+        if url == b'':
+            url = b'/'
+        if not self._url.query == b'':
+            url += b'?' + self._url.query
+        if not self._url.fragment == b'':
+            url += b'#' + self._url.fragment
+        return url
diff --git a/proxy/plugin/modify_chunk_response.py b/proxy/plugin/modify_chunk_response.py
index b353378..56ffeac 100644
--- a/proxy/plugin/modify_chunk_response.py
+++ b/proxy/plugin/modify_chunk_response.py
@@ -45,7 +45,9 @@ class ModifyChunkResponsePlugin(HttpProxyBasePlugin):
         self.response.parse(chunk.tobytes())
         # If response is complete, modify and dispatch to client
         if self.response.state == httpParserStates.COMPLETE:
-            self.response.body = b'\n'.join(self.DEFAULT_CHUNKS) + b'\n'
+            # Avoid setting a body for responses where content is not expected
+            if self.response.content_expected():
+                self.response.body = b'\n'.join(self.DEFAULT_CHUNKS) + b'\n'
             self.client.queue(memoryview(self.response.build_response()))
         return memoryview(b'')
 


Test patch:
diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py
index 29e0ceb..b5ede79 100644
--- a/tests/http/test_http_parser.py
+++ b/tests/http/test_http_parser.py
@@ -117,7 +117,7 @@ class TestHttpParser(unittest.TestCase):
 
     def test_set_host_port_raises(self) -> None:
         with self.assertRaises(KeyError):
-            self.parser.set_line_attributes()
+            self.parser._set_line_attributes()
 
     def test_find_line(self) -> None:
         self.assertEqual(
@@ -159,11 +159,11 @@ class TestHttpParser(unittest.TestCase):
         )
         self.parser.parse(pkt)
         self.assertEqual(self.parser.total_size, len(pkt))
-        self.assertEqual(self.parser.build_path(), b'/path/dir/?a=b&c=d#p=q')
+        self.assertEqual(self.parser._build_path(), b'/path/dir/?a=b&c=d#p=q')
         self.assertEqual(self.parser.method, b'GET')
-        assert self.parser.url
-        self.assertEqual(self.parser.url.hostname, b'example.com')
-        self.assertEqual(self.parser.url.port, None)
+        assert self.parser._url
+        self.assertEqual(self.parser._url.hostname, b'example.com')
+        self.assertEqual(self.parser._url.port, None)
         self.assertEqual(self.parser.version, b'HTTP/1.1')
         self.assertEqual(self.parser.state, httpParserStates.COMPLETE)
         self.assertEqual(
@@ -181,7 +181,7 @@ class TestHttpParser(unittest.TestCase):
         )
 
     def test_build_url_none(self) -> None:
-        self.assertEqual(self.parser.build_path(), b'/None')
+        self.assertEqual(self.parser._build_path(), b'/None')
 
     def test_line_rcvd_to_rcving_headers_state_change(self) -> None:
         pkt = b'GET http://localhost HTTP/1.1'
@@ -200,7 +200,7 @@ class TestHttpParser(unittest.TestCase):
         self.parser.parse(pkt)
         self.assertEqual(self.parser.total_size, len(pkt))
         self.assertEqual(self.parser.method, None)
-        self.assertEqual(self.parser.url, None)
+        self.assertEqual(self.parser._url, None)
         self.assertEqual(self.parser.version, None)
         self.assertEqual(
             self.parser.state,
@@ -210,9 +210,9 @@ class TestHttpParser(unittest.TestCase):
         self.parser.parse(CRLF)
         self.assertEqual(self.parser.total_size, len(pkt) + len(CRLF))
         self.assertEqual(self.parser.method, b'GET')
-        assert self.parser.url
-        self.assertEqual(self.parser.url.hostname, b'localhost')
-        self.assertEqual(self.parser.url.port, 8080)
+        assert self.parser._url
+        self.assertEqual(self.parser._url.hostname, b'localhost')
+        self.assertEqual(self.parser._url.port, 8080)
         self.assertEqual(self.parser.version, b'HTTP/1.1')
         self.assertEqual(self.parser.state, httpParserStates.LINE_RCVD)
 
@@ -248,9 +248,9 @@ class TestHttpParser(unittest.TestCase):
             ]),
         )
         self.assertEqual(self.parser.method, b'GET')
-        assert self.parser.url
-        self.assertEqual(self.parser.url.hostname, b'localhost')
-        self.assertEqual(self.parser.url.port, 8080)
+        assert self.parser._url
+        self.assertEqual(self.parser._url.hostname, b'localhost')
+        self.assertEqual(self.parser._url.port, 8080)
         self.assertEqual(self.parser.version, b'HTTP/1.1')
         self.assertEqual(self.parser.buffer, b'Host: ')
         self.assertEqual(self.parser.state, httpParserStates.LINE_RCVD)
@@ -295,9 +295,9 @@ class TestHttpParser(unittest.TestCase):
         ])
         self.parser.parse(raw % b'http://localhost')
         self.assertEqual(self.parser.method, b'POST')
-        assert self.parser.url
-        self.assertEqual(self.parser.url.hostname, b'localhost')
-        self.assertEqual(self.parser.url.port, None)
+        assert self.parser._url
+        self.assertEqual(self.parser._url.hostname, b'localhost')
+        self.assertEqual(self.parser._url.port, None)
         self.assertEqual(self.parser.version, b'HTTP/1.1')
         self.assertEqual(
             self.parser.headers[b'content-type'],
@@ -334,9 +334,9 @@ class TestHttpParser(unittest.TestCase):
             ]),
         )
         self.assertEqual(self.parser.method, b'POST')
-        assert self.parser.url
-        self.assertEqual(self.parser.url.hostname, b'localhost')
-        self.assertEqual(self.parser.url.port, None)
+        assert self.parser._url
+        self.assertEqual(self.parser._url.hostname, b'localhost')
+        self.assertEqual(self.parser._url.port, None)
         self.assertEqual(self.parser.version, b'HTTP/1.1')
         self.assert_state_change_with_crlf(
             httpParserStates.RCVING_HEADERS,