2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.spotify.internal.api;
15 import static org.eclipse.jetty.http.HttpStatus.*;
17 import java.util.concurrent.CompletableFuture;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.ScheduledExecutorService;
20 import java.util.concurrent.TimeUnit;
21 import java.util.concurrent.TimeoutException;
22 import java.util.function.Function;
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jetty.client.HttpClient;
26 import org.eclipse.jetty.client.api.ContentResponse;
27 import org.eclipse.jetty.client.api.Request;
28 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
29 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
30 import org.openhab.binding.spotify.internal.api.exception.SpotifyTokenExpiredException;
31 import org.slf4j.Logger;
32 import org.slf4j.LoggerFactory;
34 import com.google.gson.JsonElement;
35 import com.google.gson.JsonObject;
36 import com.google.gson.JsonParser;
37 import com.google.gson.JsonSyntaxException;
40 * Class to perform the actual call to the Spotify Api, interprets the returned Http status codes, and handles the error
41 * codes returned by the Spotify Web Api.
43 * @author Hilbrand Bouwkamp - Initial contribution
46 class SpotifyConnector {
48 private static final String RETRY_AFTER_HEADER = "Retry-After";
49 private static final String AUTHORIZATION_HEADER = "Authorization";
51 private static final int HTTP_CLIENT_TIMEOUT_SECONDS = 10;
52 private static final int HTTP_CLIENT_RETRY_COUNT = 5;
54 private final Logger logger = LoggerFactory.getLogger(SpotifyConnector.class);
56 private final JsonParser parser = new JsonParser();
57 private final HttpClient httpClient;
58 private final ScheduledExecutorService scheduler;
63 * @param scheduler Scheduler to reschedule calls when rate limit exceeded or call not ready
64 * @param httpClient http client to use to make http calls
66 public SpotifyConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
67 this.scheduler = scheduler;
68 this.httpClient = httpClient;
72 * Performs a call to the Spotify Web Api and returns the raw response. In there are problems this method can throw
73 * a Spotify exception.
75 * @param requester The function to construct the request with http client that is passed as argument to the
77 * @param authorization The authorization string to use in the Authorization header
78 * @return the raw reponse given
80 public ContentResponse request(Function<HttpClient, Request> requester, String authorization) {
81 final Caller caller = new Caller(requester, authorization);
84 return caller.call().get();
85 } catch (InterruptedException e) {
86 Thread.currentThread().interrupt();
87 throw new SpotifyException("Thread interrupted");
88 } catch (ExecutionException e) {
89 final Throwable cause = e.getCause();
91 if (cause instanceof RuntimeException) {
92 throw (RuntimeException) cause;
94 throw new SpotifyException(e.getMessage(), e);
100 * Class to handle a call to the Spotify Web Api. In case of rate limiting or not finished jobs it will retry in a
101 * specified time frame. It retries a number of times and then gives up with an exception.
103 * @author Hilbrand Bouwkamp - Initial contribution
105 private class Caller {
106 private final Function<HttpClient, Request> requester;
107 private final String authorization;
109 private final CompletableFuture<ContentResponse> future = new CompletableFuture<>();
110 private int delaySeconds;
111 private int attempts;
116 * @param requester The function to construct the request with http client that is passed as argument to the
118 * @param authorization The authorization string to use in the Authorization header
120 public Caller(Function<HttpClient, Request> requester, String authorization) {
121 this.requester = requester;
122 this.authorization = authorization;
126 * Performs the request as a Future. It will set the Future state once it's finished. This method will be
127 * scheduled again when the call is to be retried. The original caller should call the get method on the Future
128 * to wait for the call to finish. The first try is not scheduled so if it succeeds on the first call the get
129 * method directly returns the value.
131 * @return the Future holding the call
133 public CompletableFuture<ContentResponse> call() {
136 final boolean success = processResponse(
137 requester.apply(httpClient).header(AUTHORIZATION_HEADER, authorization)
138 .timeout(HTTP_CLIENT_TIMEOUT_SECONDS, TimeUnit.SECONDS).send());
141 if (attempts < HTTP_CLIENT_RETRY_COUNT) {
142 logger.debug("Spotify Web API call attempt: {}", attempts);
144 scheduler.schedule(this::call, delaySeconds, TimeUnit.SECONDS);
146 logger.debug("Giving up on accessing Spotify Web API. Check network connectivity!");
147 future.completeExceptionally(new SpotifyException(
148 "Could not reach the Spotify Web Api after " + attempts + " retries."));
151 } catch (ExecutionException e) {
152 Throwable cause = e.getCause();
154 future.completeExceptionally(cause);
156 future.completeExceptionally(e);
158 } catch (RuntimeException | TimeoutException e) {
159 future.completeExceptionally(e);
160 } catch (InterruptedException e) {
161 Thread.currentThread().interrupt();
162 future.completeExceptionally(e);
168 * Processes the response of the Spotify Web Api call and handles the HTTP status codes. The method returns true
169 * if the response indicates a successful and false if the call should be retried. If there were other problems
170 * a Spotify exception is thrown indicating no retry should be done an the user should be informed.
172 * @param response the response given by the Spotify Web Api
173 * @return true if the response indicated a successful call, false if the call should be retried
175 private boolean processResponse(ContentResponse response) {
176 boolean success = false;
178 logger.debug("Response Code: {}", response.getStatus());
179 if (logger.isTraceEnabled()) {
180 logger.trace("Response Data: {}", response.getContentAsString());
182 switch (response.getStatus()) {
186 case NOT_MODIFIED_304:
187 future.complete(response);
192 "Spotify Web API returned code 202 - The request has been accepted for processing, but the processing has not been completed.");
193 future.complete(response);
196 case BAD_REQUEST_400:
197 throw new SpotifyException(processErrorState(response));
198 case UNAUTHORIZED_401:
199 throw new SpotifyAuthorizationException(processErrorState(response));
200 case TOO_MANY_REQUESTS_429:
201 // Response Code 429 means requests rate limits exceeded.
202 final String retryAfter = response.getHeaders().get(RETRY_AFTER_HEADER);
205 "Spotify Web API returned code 429 (rate limit exceeded). Retry After {} seconds. Decrease polling interval of bridge! Going to sleep...",
207 delaySeconds = Integer.parseInt(retryAfter);
210 // Process for authorization error, and logging.
211 processErrorState(response);
212 future.complete(response);
216 throw new SpotifyException(processErrorState(response));
217 case SERVICE_UNAVAILABLE_503:
218 case INTERNAL_SERVER_ERROR_500:
219 case BAD_GATEWAY_502:
221 throw new SpotifyException("Spotify returned with error status: " + response.getStatus());
227 * Processes the responded content if the status code indicated an error. If the response could be parsed the
228 * content error message is returned. If the error indicated a token or authorization error a specific exception
229 * is thrown. If an error message is thrown the caller throws the appropriate exception based on the state with
230 * which the error was returned by the Spotify Web Api.
232 * @param response content returned by Spotify Web Api
233 * @return the error messages
235 private String processErrorState(ContentResponse response) {
237 final JsonElement element = parser.parse(response.getContentAsString());
239 if (element.isJsonObject()) {
240 final JsonObject object = element.getAsJsonObject();
241 if (object.has("error") && object.get("error").isJsonObject()) {
242 final String message = object.get("error").getAsJsonObject().get("message").getAsString();
244 // Bad request can be anything, from authorization problems to start play problems.
245 // Therefore authorization type errors are filtered and handled differently.
246 logger.debug("Bad request: {}", message);
247 if (message.contains("expired")) {
248 throw new SpotifyTokenExpiredException(message);
252 } else if (object.has("error_description")) {
253 final String errorDescription = object.get("error_description").getAsString();
255 throw new SpotifyAuthorizationException(errorDescription);
258 logger.debug("Unknown response: {}", response);
259 return "Unknown response";
260 } catch (JsonSyntaxException e) {
261 logger.debug("Response was not json: ", e);
262 return "Unknown response";