Repository: PrefectHQ/prefect

Issue:
Client produces hard-to-understand error when not authenticated
## Description

When `client.Client` is not authenticated (`prefect._api_token` is `None`), API calls generate difficult-to-understand error messages.

## Expected Behavior
*What did you expect to happen instead?*

I expected that API calls from a Client where `self._api_token is None` would throw an informative error like this (just example text I made up):

> "Client not authenticated. Please run 'prefect auth login' or set 'PREFECT__CLOUD__AUTH_TOKEN'"

Or, at a minimum, I expected that the service I'm hitting would return a 403: permission denied.

## Reproduction

Given an environment where `~/.prefect` does not exist (which means that `prefect auth login` has not been run) and where `PREFECT__CLOUD__AUTH_TOKEN` has not been set ([doc](https://docs.prefect.io/orchestration/concepts/api.html#authenticating-the-client-with-cloud)) ...

```python
import os
from prefect.client import Client

assert not os.path.isdir(os.path.join(os.environ["HOME"], ".prefect"))
assert os.environ.get("PREFECT__CLOUD__AUTH_TOKEN", None) is None
```

A client can be created successfully, and as expected its `._api_token` is `None`

```python
client = Client()
print(client._api_token)
```

> None

API calls produce difficult-to-understand errors

```python
client.get_available_tenants()
```

<details><summary>stacktrace ending in "JSONDecodeError: Expecting value: line 1 column 1 (char 0)"</summary>

```
JSONDecodeError                  Traceback (most recent call last)
<ipython-input-12-6918ccebdc07> in <module>
      1 client = Client()
      2 print(client._api_token)
----> 3 client.get_available_tenants()

~/miniconda3/lib/python3.7/site-packages/prefect/client/client.py in get_available_tenants(self)
    434             {"query": {"tenant(order_by: {slug: asc})": {"id", "slug", "name"}}},
    435             # use the API token to see all available tenants
--> 436             token=self._api_token,
    437         )  # type: ignore
    438         return result.data.tenant  # type: ignore

~/miniconda3/lib/python3.7/site-packages/prefect/client/client.py in graphql(self, query, raise_on_error, headers, variables, token)
    216             headers=headers,
    217             params=dict(query=parse_graphql(query), variables=json.dumps(variables)),
--> 218             token=token,
    219         )
    220 

~/miniconda3/lib/python3.7/site-packages/prefect/client/client.py in post(self, path, server, headers, params, token)
    176             server=server,
    177             headers=headers,
--> 178             token=token,
    179         )
    180         if response.text:

~/miniconda3/lib/python3.7/site-packages/prefect/client/client.py in _request(self, method, path, params, server, headers, token)
    331 
    332         # check if there was an API_ERROR code in the response
--> 333         if "API_ERROR" in str(response.json().get("errors")):
    334             success, retry_count = False, 0
    335             # retry up to six times

~/miniconda3/lib/python3.7/site-packages/requests/models.py in json(self, **kwargs)
    896                     # used.
    897                     pass
--> 898         return complexjson.loads(self.text, **kwargs)
    899 
    900     @property

~/miniconda3/lib/python3.7/json/__init__.py in loads(s, encoding, cls, object_hook, parse_float, parse_int, parse_constant, object_pairs_hook, **kw)
    346             parse_int is None and parse_float is None and
    347             parse_constant is None and object_pairs_hook is None and not kw):
--> 348         return _default_decoder.decode(s)
    349     if cls is None:
    350         cls = JSONDecoder

~/miniconda3/lib/python3.7/json/decoder.py in decode(self, s, _w)
    335 
    336         """
--> 337         obj, end = self.raw_decode(s, idx=_w(s, 0).end())
    338         end = _w(s, end).end()
    339         if end != len(s):

~/miniconda3/lib/python3.7/json/decoder.py in raw_decode(self, s, idx)
    353             obj, end = self.scan_once(s, idx)
    354         except StopIteration as err:
--> 355             raise JSONDecodeError("Expecting value", s, err.value) from None
    356         return obj, end

JSONDecodeError: Expecting value: line 1 column 1 (char 0)
```

</details>

## Other notes

I think that the issue is that calls like this pass `self._api_token` to `Client.graphql()` and that nothing in `Client.graphql()` checks the token before sending a request to the service.

https://github.com/PrefectHQ/prefect/blob/5108a3e3b09929a4a478d45f7f75737fac21f7f0/src/prefect/client/client.py#L433-L437

I haven't had time to dig in deeper, but I suspect that this handling of a null token in `Client._request()` is doing something unexpected

https://github.com/PrefectHQ/prefect/blob/5108a3e3b09929a4a478d45f7f75737fac21f7f0/src/prefect/client/client.py#L301-L302

## Environment
*Any additional information about your environment*

<details><summary>output of "conda info"</summary>

I'm using `prefect` 0.12.0, and the API I'm hitting is Prefect Cloud (I'm not running Prefect Core Server myself).

```
active environment : None
       user config file : /home/jlamb/.condarc
 populated config files : 
          conda version : 4.8.3
    conda-build version : not installed
         python version : 3.7.6.final.0
       virtual packages : __glibc=2.27
       base environment : /home/jlamb/miniconda3  (writable)
           channel URLs : https://repo.anaconda.com/pkgs/main/linux-64
                          https://repo.anaconda.com/pkgs/main/noarch
                          https://repo.anaconda.com/pkgs/r/linux-64
                          https://repo.anaconda.com/pkgs/r/noarch
          package cache : /home/jlamb/miniconda3/pkgs
                          /home/jlamb/.conda/pkgs
       envs directories : /home/jlamb/miniconda3/envs
                          /home/jlamb/.conda/envs
               platform : linux-64
             user-agent : conda/4.8.3 requests/2.23.0 CPython/3.7.6 Linux/5.3.0-7625-generic ubuntu/18.04.4 glibc/2.27
                UID:GID : 1000:1000
             netrc file : None
           offline mode : False
```
</details>

*Optionally run `prefect diagnostics` from the command line and paste the information here*

```
{
  "config_overrides": {},
  "env_vars": [],
  "system_information": {
    "platform": "Linux-5.3.0-7625-generic-x86_64-with-debian-buster-sid",
    "prefect_version": "0.12.0",
    "python_version": "3.7.6"
  }
}
```

Thanks for your time and consideration.


Discussion:
Time permitting, this weekend I'd like to try to fix this if maintainers don't get to it sooner.
@jameslamb Go for it! We should definitely improve that error message when in communication with _Cloud_ however no auth token is needed or used when in communication with Core's server. Something to keep in mind 🙂 
Thank you! I didn't realize that core's server didn't have any auth enabled, so I didn't realize the behavior I saw was Prefect Cloud specific.
Apologies for jumping in, but I might want to take a look at this first --> there are certain classes of requests that our Cloud API protection blocks entirely, and this results in a non-JSON decodable response and I suspect that's happening here.  I want to see if I can recreate and if so, make sure that we catch / handle such situations appropriately.   I'll report back!

Resolving patch:
diff --git a/src/prefect/client/client.py b/src/prefect/client/client.py
--- a/src/prefect/client/client.py
+++ b/src/prefect/client/client.py
@@ -329,8 +329,19 @@ def _request(
             session=session, method=method, url=url, params=params, headers=headers
         )
 
+        # parse the response
+        try:
+            json_resp = response.json()
+        except json.JSONDecodeError:
+            if prefect.config.backend == "cloud" and "Authorization" not in headers:
+                raise ClientError(
+                    "Malformed response received from Cloud - please ensure that you have an API token properly configured."
+                )
+            else:
+                raise ClientError(f"Malformed response received from API.")
+
         # check if there was an API_ERROR code in the response
-        if "API_ERROR" in str(response.json().get("errors")):
+        if "API_ERROR" in str(json_resp.get("errors")):
             success, retry_count = False, 0
             # retry up to six times
             while success is False and retry_count < 6:
diff --git a/src/prefect/tasks/prefect/flow_run.py b/src/prefect/tasks/prefect/flow_run.py
--- a/src/prefect/tasks/prefect/flow_run.py
+++ b/src/prefect/tasks/prefect/flow_run.py
@@ -1,4 +1,5 @@
 from typing import Any
+from urllib.parse import urlparse
 
 from prefect import config, Task
 from prefect.client import Client
@@ -64,10 +65,12 @@ def run(
             ```
 
         """
+        is_hosted_backend = "prefect.io" in urlparse(config.cloud.api).netloc
+
         # verify that flow and project names were passed where necessary
         if flow_name is None:
             raise ValueError("Must provide a flow name.")
-        if project_name is None and config.backend == "cloud":
+        if project_name is None and is_hosted_backend:
             raise ValueError("Must provide a project name.")
 
         where_clause = {


Test patch: