"""Spotify API wrapper.""" import base64 import time from typing import Dict from lambdacommon.common_config import logger import requests from requests.exceptions import RequestException from src.common.types import BaseStoreAPI class SpotifyAPI(BaseStoreAPI): """Spotify API wrapper class.""" def __init__(self, client_id: str, client_secret: str): """Initialize. Args: client_id: Spotify API client id. client_secret: Spotify API secret. """ self._client_id = client_id self._client_secret = client_secret self._sleep_time = 0.0 self._recreate_access_token_for_creds() def _obtain_access_token(self) -> str: """Generate access token.""" auth_token = f'{self._client_id}:{self._client_secret}' auth_token_encoded = base64.b64encode(auth_token.encode()).decode() try: response = requests.post( 'https://accounts.spotify.com/api/token', data={'grant_type': 'client_credentials'}, headers={'Authorization': f'Basic {auth_token_encoded}'}, timeout=5, ) except RequestException as e: logger.warning(f'Error obtaining Spotify access token: {e}') raise ValueError(f'Error obtaining Spotify access token: {e}') try: response_json = response.json() except ValueError: logger.warning('Failed to parse access token response') raise ValueError(response.text) if 'access_token' not in response_json: logger.warning('Failed to obtain Spotify access token') raise ValueError(response_json) return response_json['access_token'] def _auth_headers(self) -> Dict: """Auth headers.""" return {'Authorization': f'Bearer {self._access_token}'} def _recreate_access_token_for_creds(self): """Recreate access token.""" self._access_token = self._obtain_access_token() def call(self, endpoint: str, params: Dict) -> requests.Response: """Call Spotify API with a GET method. Args: endpoint: An endpoint from here https://developer.spotify.com/console/ params: API request params according to the documentation. returns: HTTP response from the API. """ attempt_number = 0 last_error_status = None # Only allow one token refresh attempt for a 403 to avoid looping # when Spotify returns repeated 403 responses that refreshing # won't fix. refreshed_on_403 = False while attempt_number < 5: attempt_number += 1 logger.debug(f'Making API call, attempt number #{attempt_number}') # Get fresh headers for each attempt (important after # token refresh) headers = self._auth_headers() try: response = requests.get( f'https://api.spotify.com/v1{endpoint}', params=params, headers=headers, timeout=10) except RequestException as e: logger.warning(f'Spotify API request exception: {e}') # Return a synthetic Response with 500 so upper layers # treat it as failure resp = requests.models.Response() resp.status_code = 500 resp._content = str(e).encode() return resp logger.debug(f'Response status code {response.status_code}') if response.status_code == 401: logger.debug('Received 401 Unauthorized, refreshing token') last_error_status = 401 self._recreate_access_token_for_creds() elif response.status_code == 403: # Treat 403 Forbidden as an expired/invalid token in some # Spotify responses — refresh the token and retry the call. logger.debug('Received 403 Forbidden, refreshing token') last_error_status = 403 self._recreate_access_token_for_creds() elif response.status_code == 429: retry_after = int(response.headers.get('retry-after', 0)) logger.debug(f'Too many requests. Next try in {retry_after}') last_error_status = 429 self._sleep_time += retry_after time.sleep(self._sleep_time) elif response.status_code == 403: # Treat 403 Forbidden as an expired/invalid token in some # Spotify responses — but only refresh once to avoid # continuous retry loops if refreshing doesn't help. logger.debug('Received 403 Forbidden, refreshing token') last_error_status = 403 if not refreshed_on_403: refreshed_on_403 = True self._recreate_access_token_for_creds() # continue to next attempt which will use refreshed headers continue else: # already refreshed once for 403, give up and return logger.debug('Already refreshed once for 403; returning') return response else: self._sleep_time = 0.0 if last_error_status and response.status_code == 200: logger.info( f'Recovered from {last_error_status} error on ' f'attempt #{attempt_number}') logger.debug('Returning response') return response logger.warning( f'Returning response after {attempt_number} attempts. ' f'Last error status: {last_error_status}') return response