Repository: googleapis/google-cloud-python

Issue:
Storage: error on HTTP 206 Partial Content discards message leading to confusing errors
In `download_to_file`,[ if the download code raises `resumable_media.InvalidResponse` [1], it calls `_raise_from_invalid_response`](https://github.com/GoogleCloudPlatform/google-cloud-python/blob/master/storage/google/cloud/storage/blob.py#L499), which calls `exceptions.from_http_response`, which [creates a new exception using the HTTP response inside the `resumable_media.InvalidResponse`](https://github.com/GoogleCloudPlatform/google-cloud-python/blob/master/core/google/api/core/exceptions.py#L386), which discards any exception message that was put there. This leads to confusing exceptions like the following (full stack trace below):

```
google.api.core.exceptions.GoogleAPICallError
206 GET https://www.googleapis.com/download/storage/v1/b/bucket_name/o/object_name?alt=media: ��eC�uXS@�..."
```

This shows a 206 SUCCESS code, but apparently it still failed for some reason. Digging through the code shows a number of reasons this can happen, such as [if the response body length does not match the Content-Length header](https://github.com/GoogleCloudPlatform/google-resumable-media-python/blob/master/google/resumable_media/_download.py#L353).

I'm not entirely sure how to fix this, but I would be willing to contribute a PR if someone can suggest a solution. My suggestion would be to change `_raise_from_invalid_response` to replace the message on the exception that is raised with the message from the `InvalidResponse` object itself (maybe formatted with `''.join(str(s for s in e.args))`).

*Details*
*OS type and version*: Linux
*Python version*: Python 2.7.13
*google-cloud-python version*: 1.4.0 (but code inspection shows this still exists):
```
google-cloud-core==0.27.1
google-cloud-datastore==1.3.0
google-cloud-storage==1.4.0
```

*Stacktrace*:
```
File "app_code.py", line 123, in some_function
  src_blob.download_to_file(src_temp)
File "/usr/local/lib/python2.7/site-packages/google/cloud/storage/blob.py", line 466, in download_to_file
  _raise_from_invalid_response(exc)
File "/usr/local/lib/python2.7/site-packages/google/cloud/storage/blob.py", line 1605, in _raise_from_invalid_response
  raise exceptions.from_http_response(error.response)
```

*Steps to reproduce*

* Somehow get a GCS download to return a 206 code then truncate the response. I don't actually know how to reproduce this.



Discussion:
A 206 just means it was a partial response (i.e. if you are downloading in chunks). 

This is**really** tough to debug / code for without being able to reproduce. If you at least **see** it again, the stacktrace in Python 3 would be more useful (due to `raise from` semantics), e.g.

```python
In [1]: def f():
   ...:     raise ValueError
   ...:

In [2]: def g():
   ...:     try:
   ...:         f()
   ...:     except:
   ...:         raise RuntimeError
   ...:

In [3]: g()
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-2-b4762a3d7a10> in g()
      2     try:
----> 3         f()
      4     except:

<ipython-input-1-1e378d1a95b8> in f()
      1 def f():
----> 2     raise ValueError

ValueError:

During handling of the above exception, another exception occurred:

RuntimeError                              Traceback (most recent call last)
<ipython-input-3-5fd69ddb5074> in <module>()
----> 1 g()

<ipython-input-2-b4762a3d7a10> in g()
      3         f()
      4     except:
----> 5         raise RuntimeError

RuntimeError:

In [4]:
```

 Do you have a **requirement** to use 2.7? 

It seems we should be doing **at least** two things here:

- Keeping around the caught exception (rather than just it's status)
- Using `six.raise_from` to give context
Python 2.7: We have a large legacy App Engine Standard code base unfortunately. This particular thing does not run on App Engine, but it interacts with libraries that were written for 2.7, so unfortunately we need to use it.

However: Yes, I think using `six.raise_from` would effectively solve this problem. (not for me due to 2.7, but I can accept that Python 3 users will see the correct thing).

As you might expect: I've basically seen this once, but this mystery did kill a couple hours today, since I was trying to make sure we didn't have a thread-safety issue (due to the weirdness of throwing an exception for a success HTTP status code), and because the binary data in the exception caused a unicode handling error.

This code is downloading in chunks because I set the chunk size to 32 MiB at some point, maybe due to https://github.com/GoogleCloudPlatform/google-cloud-python/issues/2222. (Now that I look at this, I know the storage library has changed and its probably hurting us to do this, since it will now do it in a single request if I don't specify the chunk size ...)

The log for this error contained the follow extra information:

```
Starting new HTTPS connection (1): www.googleapis.com
https://www.googleapis.com:443 "GET /download/storage/v1/b/bucket/o/object?alt=media HTTP/1.1" 206 268412
... (many lines from chardet since the handler tries to access response.txt and the exception includes binary) ...
```

This particular object *is* 268412 bytes, so I suspect length wasn't the problem. Unfortunately, due to the way the exception is handled, I have no way to tell what actually caused it :(

> It seems we should be doing at least two things here:
> - Keeping around the caught exception (rather than just it's status)
> - Using six.raise_from to give context

`google.api_core.exceptions._from_http_response` tries hard to preserve any error message or errors from the response's payload.  The issue here is that there *are* no errors:  a [206 response](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.7) isn't an error.

If `google.resumable_media` is raising an `InvalidResponse` exception, ISTM that we should just let it propagate:  there is no point in rewrapping it.  @dhermes is the person who knew enough to argue otherwise, but he's on leave at the moment.


Resolving patch:
diff --git a/storage/google/cloud/storage/blob.py b/storage/google/cloud/storage/blob.py
--- a/storage/google/cloud/storage/blob.py
+++ b/storage/google/cloud/storage/blob.py
@@ -1870,7 +1870,16 @@ def _raise_from_invalid_response(error):
     :raises: :class:`~google.cloud.exceptions.GoogleCloudError` corresponding
              to the failed status code
     """
-    raise exceptions.from_http_response(error.response)
+    response = error.response
+    error_message = str(error)
+
+    message = u'{method} {url}: {error}'.format(
+        method=response.request.method,
+        url=response.request.url,
+        error=error_message)
+
+    raise exceptions.from_http_status(
+        response.status_code, message, response=response)
 
 
 def _add_query_parameters(base_url, name_value_pairs):


Test patch:
diff --git a/storage/tests/unit/test_blob.py b/storage/tests/unit/test_blob.py
--- a/storage/tests/unit/test_blob.py
+++ b/storage/tests/unit/test_blob.py
@@ -1637,18 +1637,17 @@ def test_upload_from_file_failure(self):
         from google.resumable_media import InvalidResponse
         from google.cloud import exceptions
 
-        message = b'Someone is already in this spot.'
+        message = 'Someone is already in this spot.'
         response = requests.Response()
-        response._content = message
         response.status_code = http_client.CONFLICT
         response.request = requests.Request(
             'POST', 'http://example.com').prepare()
-        side_effect = InvalidResponse(response)
+        side_effect = InvalidResponse(response, message)
 
         with self.assertRaises(exceptions.Conflict) as exc_info:
             self._upload_from_file_helper(side_effect=side_effect)
 
-        self.assertIn(message.decode('utf-8'), exc_info.exception.message)
+        self.assertIn(message, exc_info.exception.message)
         self.assertEqual(exc_info.exception.errors, [])
 
     def _do_upload_mock_call_helper(self, blob, client, content_type, size):
@@ -1784,17 +1783,16 @@ def test_create_resumable_upload_session_with_failure(self):
         from google.resumable_media import InvalidResponse
         from google.cloud import exceptions
 
-        message = b'5-oh-3 woe is me.'
+        message = '5-oh-3 woe is me.'
         response = self._mock_requests_response(
-            content=message, status_code=http_client.SERVICE_UNAVAILABLE,
-            headers={})
-        side_effect = InvalidResponse(response)
+            status_code=http_client.SERVICE_UNAVAILABLE, headers={})
+        side_effect = InvalidResponse(response, message)
 
         with self.assertRaises(exceptions.ServiceUnavailable) as exc_info:
             self._create_resumable_upload_session_helper(
                 side_effect=side_effect)
 
-        self.assertIn(message.decode('utf-8'), exc_info.exception.message)
+        self.assertIn(message, exc_info.exception.message)
         self.assertEqual(exc_info.exception.errors, [])
 
     def test_get_iam_policy(self):
@@ -2891,34 +2889,41 @@ def test_do_rewind(self):
 class Test__raise_from_invalid_response(unittest.TestCase):
 
     @staticmethod
-    def _call_fut(*args, **kwargs):
+    def _call_fut(error):
         from google.cloud.storage.blob import _raise_from_invalid_response
 
-        return _raise_from_invalid_response(*args, **kwargs)
+        return _raise_from_invalid_response(error)
 
-    def _helper(self, message, **kwargs):
+    def _helper(self, message, code=http_client.BAD_REQUEST, args=()):
         import requests
 
         from google.resumable_media import InvalidResponse
-        from google.cloud import exceptions
+        from google.api_core import exceptions
 
         response = requests.Response()
         response.request = requests.Request(
             'GET', 'http://example.com').prepare()
-        response.status_code = http_client.BAD_REQUEST
-        response._content = message
-        error = InvalidResponse(response)
+        response.status_code = code
+        error = InvalidResponse(response, message, *args)
 
-        with self.assertRaises(exceptions.BadRequest) as exc_info:
-            self._call_fut(error, **kwargs)
+        with self.assertRaises(exceptions.GoogleAPICallError) as exc_info:
+            self._call_fut(error)
 
         return exc_info
 
     def test_default(self):
-        message = b'Failure'
+        message = 'Failure'
         exc_info = self._helper(message)
-        message_str = message.decode('utf-8')
-        expected = 'GET http://example.com/: {}'.format(message_str)
+        expected = 'GET http://example.com/: {}'.format(message)
+        self.assertEqual(exc_info.exception.message, expected)
+        self.assertEqual(exc_info.exception.errors, [])
+
+    def test_w_206_and_args(self):
+        message = 'Failure'
+        args = ('one', 'two')
+        exc_info = self._helper(
+            message, code=http_client.PARTIAL_CONTENT, args=args)
+        expected = 'GET http://example.com/: {}'.format((message,) + args)
         self.assertEqual(exc_info.exception.message, expected)
         self.assertEqual(exc_info.exception.errors, [])