Repository: spectacles-ci/spectacles

Issue:
Log response from Looker in handle_exceptions
We should catch and handle `LookerApiError` specifically [here](https://github.com/spectacles-ci/spectacles/blob/master/spectacles/cli.py#L148).

The exception object has a `response` attribute which is the raw response from Looker. We can log (at a debug level) the response which will help a lot with debugging (even though, sometimes the message from Looker is not useful).

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/spectacles-ci/spectacles/pull/217?src=pr&el=h1) Report
> Merging [#217](https://codecov.io/gh/spectacles-ci/spectacles/pull/217?src=pr&el=desc) into [master](https://codecov.io/gh/spectacles-ci/spectacles/commit/bee3ec4fa8841f2f731e5745c25df3902763108d&el=desc) will **increase** coverage by `0.04%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/spectacles-ci/spectacles/pull/217/graphs/tree.svg?width=650&height=150&src=pr&token=EWgmmQti5j)](https://codecov.io/gh/spectacles-ci/spectacles/pull/217?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #217      +/-   ##
==========================================
+ Coverage   89.37%   89.41%   +0.04%     
==========================================
  Files          11       11              
  Lines        1016     1020       +4     
==========================================
+ Hits          908      912       +4     
  Misses        108      108              
```


| [Impacted Files](https://codecov.io/gh/spectacles-ci/spectacles/pull/217?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [spectacles/cli.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/217/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9jbGkucHk=) | `78.70% <100.00%> (+0.56%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/spectacles-ci/spectacles/pull/217?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/spectacles-ci/spectacles/pull/217?src=pr&el=footer). Last update [bee3ec4...bb99800](https://codecov.io/gh/spectacles-ci/spectacles/pull/217?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/spectacles/cli.py b/spectacles/cli.py
index d0a8a73..9eb3447 100644
--- a/spectacles/cli.py
+++ b/spectacles/cli.py
@@ -1,6 +1,7 @@
 from pathlib import Path
 import sys
 import yaml
+import json
 from yaml.parser import ParserError
 import argparse
 import logging
@@ -9,7 +10,11 @@ from typing import Callable, Iterable, List
 from spectacles import __version__
 from spectacles.runner import Runner
 from spectacles.client import LookerClient
-from spectacles.exceptions import SpectaclesException, GenericValidationError
+from spectacles.exceptions import (
+    LookerApiError,
+    SpectaclesException,
+    GenericValidationError,
+)
 from spectacles.logger import GLOBAL_LOGGER as logger, set_file_handler
 import spectacles.printer as printer
 import spectacles.tracking as tracking
@@ -145,6 +150,23 @@ def handle_exceptions(function: Callable) -> Callable:
             return function(*args, **kwargs)
         except GenericValidationError as error:
             sys.exit(error.exit_code)
+        except LookerApiError as error:
+            logger.error(
+                f"\n{error}\n\n"
+                + printer.dim(
+                    "Run in verbose mode (-v) or check your log file to see the full "
+                    "response from the Looker API. "
+                    "For support, please create an issue at "
+                    "https://github.com/spectacles-ci/spectacles/issues"
+                )
+                + "\n"
+            )
+            looker_api_response = json.dumps(error.looker_api_response, indent=2)
+            logger.debug(
+                f"Spectacles received a {error.status} response code from "
+                f"the Looker API with the following details: {looker_api_response}\n"
+            )
+            sys.exit(error.exit_code)
         except SpectaclesException as error:
             logger.error(
                 f"\n{error}\n\n"


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index f4e655a..106f16f 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,8 +1,14 @@
-from unittest.mock import patch
+from unittest.mock import patch, Mock
+import logging
 import pytest
+import requests
 from tests.constants import ENV_VARS
 from spectacles.cli import main, create_parser, handle_exceptions
-from spectacles.exceptions import SpectaclesException, GenericValidationError
+from spectacles.exceptions import (
+    LookerApiError,
+    SpectaclesException,
+    GenericValidationError,
+)
 
 
 @pytest.fixture
@@ -57,6 +63,37 @@ def test_handle_exceptions_unhandled_error(exception, exit_code):
     assert pytest_error.value.code == exit_code
 
 
+def test_handle_exceptions_looker_error_should_log_response_and_status(caplog):
+    caplog.set_level(logging.DEBUG)
+    response = Mock(spec=requests.Response)
+    response.request = Mock(spec=requests.PreparedRequest)
+    response.request.url = "https://api.looker.com"
+    response.request.method = "GET"
+    response.json.return_value = {
+        "message": "Not found",
+        "documentation_url": "http://docs.looker.com/",
+    }
+    status = 404
+
+    @handle_exceptions
+    def raise_exception():
+        raise LookerApiError(
+            name="exception-name",
+            title="An exception occurred.",
+            detail="Couldn't handle the truth. Please try again.",
+            status=status,
+            response=response,
+        )
+
+    with pytest.raises(SystemExit) as pytest_error:
+        raise_exception()
+    captured = "\n".join(record.msg for record in caplog.records)
+    assert str(status) in captured
+    assert '"message": "Not found"' in captured
+    assert '"documentation_url": "http://docs.looker.com/"' in captured
+    assert pytest_error.value.code == 101
+
+
 def test_parse_args_with_no_arguments_supplied(clean_env, capsys):
     parser = create_parser()
     with pytest.raises(SystemExit):