package oakbot.chat; import java.io.IOException; import java.io.InputStream; import java.net.SocketException; import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.net.ssl.SSLHandshakeException; import org.apache.http.NoHttpResponseException; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.client.utils.HttpClientUtils; import org.apache.http.conn.ConnectTimeoutException; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.util.EntityUtils; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; /** * Executes an HTTP request, retrying after a short pause if the request fails * due to glitchy network problems such as socket timeouts and empty HTTP * responses. * @author Michael Angstadt */ public class RobustClient { private static final Logger logger = Logger.getLogger(RobustClient.class.getName()); private static final Pattern response409Regex = Pattern.compile("\\d+"); private final CloseableHttpClient client; private final HttpUriRequest request; private long retryPause = 5000; private int maxAttempts = 3, attempts; private List expectedStatusCodes; /** * @param client the HTTP client * @param request the request to send */ public RobustClient(CloseableHttpClient client, HttpUriRequest request) { this.client = client; this.request = request; } /** * Sets the amount of time to wait between retries (defaults to 5 seconds). * @param retryPause the amount of time in milliseconds * @return this */ public RobustClient retryPause(long retryPause) { this.retryPause = retryPause; return this; } /** * Sets the number of times to try sending the request before giving up * (defaults to 3). * @param attempts the number of attempts (must be greater than zero) * @return this */ public RobustClient attempts(Integer attempts) { this.maxAttempts = attempts; return this; } /** *

* Sets the status code(s) that are expected to be returned in the response. * If one of the status codes in this list is not returned, then the request * will be retried. By default, ALL status codes are accepted. *

*

* HTTP 404 responses are always treated as valid and are always returned. * HTTP 409 responses, which indicate that the bot is sending messages too * quickly, are automatically retried. *

* @param statusCodes the status codes * @return this */ public RobustClient statusCodes(Integer... statusCodes) { this.expectedStatusCodes = Arrays.asList(statusCodes); return this; } /** * Sends the request, parsing the response body as JSON. If the body does * not contain valid JSON, then the request is retried. * @return the response * @throws IOException if a valid response was not returned after the * specified number of attempts */ public JsonResponse asJson() throws IOException { attempts = 0; ObjectMapper mapper = new ObjectMapper(); while (attempts 0) { logger.info("Sleeping for " + sleep + " ms before resending the request..."); try { Thread.sleep(sleep); } catch (InterruptedException e) { throw new RuntimeException(e); } } /* * Update the sleep amount for the next attempt (if there is one). */ sleep = attempts * retryPause; if (sleep > maxSleep) { sleep = maxSleep; } /* * Send the request. */ CloseableHttpResponse response; try { response = client.execute(request); } catch (NoHttpResponseException | SocketException | ConnectTimeoutException | SSLHandshakeException e) { logger.log(Level.SEVERE, e.getClass().getSimpleName() + " thrown from request " + request.getURI() + ". Retrying.", e); continue; } int actualStatusCode = response.getStatusLine().getStatusCode(); /* * An HTTP 409 response means that the bot is sending messages too * quickly. The response body contains the number of seconds the bot * must wait before it can post another message. */ if (actualStatusCode == 409) { String body = EntityUtils.toString(response.getEntity()); logger.info("HTTP " + actualStatusCode + " returned [url=" + request.getURI() + "]: " + body); Long waitTime = parse409Response(body); sleep = (waitTime == null) ? 5000 : waitTime; //do not count this against the max attempts attempts--; HttpClientUtils.closeQuietly(response); continue; } /** * The bot sometimes gets HTTP 429 ("too many requests") responses * when pinging the chat rooms for messages. This issue first * occurred when the bot was in seven rooms at once, so it may have * reached some kind of usage limit. It appears the bot can be in * five rooms at a time without triggering this response code. */ if (actualStatusCode == 429) { String body = EntityUtils.toString(response.getEntity()); logger.info("HTTP " + actualStatusCode + " returned [url=" + request.getURI() + "]: " + body); sleep = 5000; HttpClientUtils.closeQuietly(response); continue; } /* * Different requests handle 404s differently, so return the * response if it's a 404. */ if (actualStatusCode == 404) { return response; } /* * If the status code was incorrect, re-send the request. */ if (expectedStatusCodes != null && !expectedStatusCodes.contains(actualStatusCode)) { String body = EntityUtils.toString(response.getEntity()); logger.severe("The following status codes were expected " + expectedStatusCodes + ", but the actual status code was " + actualStatusCode + ". Retrying. The response body was: " + body); HttpClientUtils.closeQuietly(response); continue; } return response; } throw new IOException("Request to " + request.getURI() + " could not be sent after " + attempts + " attempts."); } /** * Parses an HTTP 409 response, which indicates that the bot is sending * messages too quickly. * @param response the HTTP 409 response body (e.g. "You can perform this * action again in 2 seconds") * @return the amount of time (in milliseconds) the bot must wait before the * chat system will accept new messages, or null if this value could not be * parsed from the response * @throws IOException if there's a problem getting the response body */ private static Long parse409Response(String body) throws IOException { Matcher m = response409Regex.matcher(body); if (!m.find()) { return null; } int seconds = Integer.parseInt(m.group(0)); return TimeUnit.SECONDS.toMillis(seconds); } /** * Represents an HTTP response whose body contains JSON. * @author Michael Angstadt */ public static class JsonResponse { private final CloseableHttpResponse response; private final JsonNode body; private final boolean http404; public JsonResponse(CloseableHttpResponse response, JsonNode body) { this.response = response; this.body = body; this.http404 = false; } public JsonResponse(CloseableHttpResponse response, boolean http404) { this.response = response; this.body = null; this.http404 = true; } public CloseableHttpResponse getResponse() { return response; } public JsonNode getBody() { return body; } public boolean isHttp404() { return http404; } } }