Repository: beetbox/beets

Issue:
Spotify does not exit gracefully when some API errors are encountered
Spotify hangs for a very long time and errors out:

```bash
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/urllib3/connectionpool.py", line 382, in _make_request
    self._validate_conn(conn)
  File "/usr/lib/python3/dist-packages/urllib3/connectionpool.py", line 1012, in _validate_conn
    conn.connect()
  File "/usr/lib/python3/dist-packages/urllib3/connection.py", line 411, in connect
    self.sock = ssl_wrap_socket(
  File "/usr/lib/python3/dist-packages/urllib3/util/ssl_.py", line 449, in ssl_wrap_socket
    ssl_sock = _ssl_wrap_socket_impl(
  File "/usr/lib/python3/dist-packages/urllib3/util/ssl_.py", line 493, in _ssl_wrap_socket_impl
    return ssl_context.wrap_socket(sock, server_hostname=server_hostname)
  File "/usr/lib/python3.10/ssl.py", line 513, in wrap_socket
    return self.sslsocket_class._create(
  File "/usr/lib/python3.10/ssl.py", line 1071, in _create
    self.do_handshake()
  File "/usr/lib/python3.10/ssl.py", line 1342, in do_handshake
    self._sslobj.do_handshake()
TimeoutError: [Errno 110] Connection timed out

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/arsaboo/.local/lib/python3.10/site-packages/requests/adapters.py", line 486, in send
    resp = conn.urlopen(
  File "/usr/lib/python3/dist-packages/urllib3/connectionpool.py", line 755, in urlopen
    retries = retries.increment(
  File "/usr/lib/python3/dist-packages/urllib3/util/retry.py", line 532, in increment
    raise six.reraise(type(error), error, _stacktrace)
  File "/usr/lib/python3/dist-packages/six.py", line 719, in reraise
    raise value
  File "/usr/lib/python3/dist-packages/urllib3/connectionpool.py", line 699, in urlopen
    httplib_response = self._make_request(
  File "/usr/lib/python3/dist-packages/urllib3/connectionpool.py", line 385, in _make_request
    self._raise_timeout(err=e, url=url, timeout_value=conn.timeout)
  File "/usr/lib/python3/dist-packages/urllib3/connectionpool.py", line 336, in _raise_timeout
    raise ReadTimeoutError(
urllib3.exceptions.ReadTimeoutError: HTTPSConnectionPool(host='api.spotify.com', port=443): Read timed out. (read timeout=None)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/arsaboo/.local/bin/beet", line 8, in <module>
    sys.exit(main())
  File "/home/arsaboo/.local/lib/python3.10/site-packages/beets/ui/__init__.py", line 1301, in main
    _raw_main(args)
  File "/home/arsaboo/.local/lib/python3.10/site-packages/beets/ui/__init__.py", line 1288, in _raw_main
    subcommand.func(lib, suboptions, subargs)
  File "/home/arsaboo/.local/lib/python3.10/site-packages/beetsplug/spotify.py", line 459, in func
    self._fetch_info(items, ui.should_write(), opts.force_refetch)
  File "/home/arsaboo/.local/lib/python3.10/site-packages/beetsplug/spotify.py", line 638, in _fetch_info
    popularity = self.track_popularity(spotify_track_id)
  File "/home/arsaboo/.local/lib/python3.10/site-packages/beetsplug/spotify.py", line 656, in track_popularity
    track_data = self._handle_response(
  File "/home/arsaboo/.local/lib/python3.10/site-packages/beetsplug/spotify.py", line 174, in _handle_response
    response = request_type(
  File "/home/arsaboo/.local/lib/python3.10/site-packages/requests/api.py", line 73, in get
    return request("get", url, params=params, **kwargs)
  File "/home/arsaboo/.local/lib/python3.10/site-packages/requests/api.py", line 59, in request
    return session.request(method=method, url=url, **kwargs)
  File "/home/arsaboo/.local/lib/python3.10/site-packages/requests/sessions.py", line 589, in request
    resp = self.send(prep, **send_kwargs)
  File "/home/arsaboo/.local/lib/python3.10/site-packages/requests/sessions.py", line 703, in send
    r = adapter.send(request, **kwargs)
  File "/home/arsaboo/.local/lib/python3.10/site-packages/requests/adapters.py", line 532, in send
    raise ReadTimeout(e, request=request)
requests.exceptions.ReadTimeout: HTTPSConnectionPool(host='api.spotify.com', port=443): Read timed out. (read timeout=None)
```


Discussion:

Resolving patch:
diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py
--- a/beetsplug/spotify.py
+++ b/beetsplug/spotify.py
@@ -171,37 +171,47 @@ def _handle_response(self, request_type, url, params=None):
         :return: JSON data for the class:`Response <Response>` object.
         :rtype: dict
         """
-        response = request_type(
-            url,
-            headers={'Authorization': f'Bearer {self.access_token}'},
-            params=params,
-        )
-        if response.status_code != 200:
-            if 'token expired' in response.text:
+        try:
+            response = request_type(
+                url,
+                headers={'Authorization': f'Bearer {self.access_token}'},
+                params=params,
+                timeout=10,
+            )
+            response.raise_for_status()
+            return response.json()
+        except requests.exceptions.ReadTimeout:
+            self._log.error('ReadTimeout.')
+            raise SpotifyAPIError('Request timed out.')
+        except requests.exceptions.RequestException as e:
+            if e.response.status_code == 401:
                 self._log.debug(
-                    '{} access token has expired. Reauthenticating.',
-                    self.data_source,
+                    f'{self.data_source} access token has expired. '
+                    f'Reauthenticating.'
                 )
                 self._authenticate()
                 return self._handle_response(request_type, url, params=params)
-            elif response.status_code == 429:
+            elif e.response.status_code == 404:
+                raise SpotifyAPIError(f'API Error: {e.response.status_code}\n'
+                                      f'URL: {url}\nparams: {params}')
+            elif e.response.status_code == 429:
                 seconds = response.headers.get('Retry-After',
                                                DEFAULT_WAITING_TIME)
-                self._log.debug('Too many API requests. Retrying after {} \
-                    seconds.', seconds)
+                self._log.debug(f'Too many API requests. Retrying after '
+                                f'{seconds} seconds.')
                 time.sleep(int(seconds) + 1)
                 return self._handle_response(request_type, url, params=params)
-            elif response.status_code == 404:
-                raise SpotifyAPIError("API Error: {}\nURL: {}\nparams: {}".
-                                      format(response.status_code, url,
-                                             params))
-            else:
-                raise ui.UserError(
-                    '{} API error:\n{}\nURL:\n{}\nparams:\n{}'.format(
-                        self.data_source, response.text, url, params
-                    )
+            elif e.response.status_code == 503:
+                self._log.error('Service Unavailable.')
+                raise SpotifyAPIError('Service Unavailable.')
+            elif e.response is not None:
+                raise SpotifyAPIError(
+                    f'{self.data_source} API error:\n{e.response.text}\n'
+                    f'URL:\n{url}\nparams:\n{params}'
                 )
-        return response.json()
+            else:
+                self._log.error(f'Request failed. Error: {e}')
+                raise SpotifyAPIError('Request failed.')
 
     def album_for_id(self, album_id):
         """Fetch an album by its Spotify ID or URL and return an
@@ -656,8 +666,8 @@ def track_popularity(self, track_id=None):
         track_data = self._handle_response(
             requests.get, self.track_url + track_id
         )
-        self._log.debug('track_data: {}', track_data['popularity'])
-        return track_data['popularity']
+        self._log.debug('track_data: {}', track_data.get('popularity'))
+        return track_data.get('popularity')
 
     def track_audio_features(self, track_id=None):
         """Fetch track audio features by its Spotify ID."""


Test patch: