Bug 945449 - FxAccountsClient should support auth server's backoff protocol. r=ferjm
authorSam Penrose <spenrose@mozilla.com>
Wed, 26 Feb 2014 10:11:49 -0800
changeset 171099 4cd10b3c37d63ebe1a40451e90f440b27c9dd4e6
parent 171098 0db8f1b96e13e669c375eaa588cb27f127195a5b
child 171100 b2baefa192ff24dff30a360c8a6770357fe4b58d
push id270
push userpvanderbeken@mozilla.com
push dateThu, 06 Mar 2014 09:24:21 +0000
reviewersferjm
bugs945449
milestone30.0a1
Bug 945449 - FxAccountsClient should support auth server's backoff protocol. r=ferjm
services/common/hawkclient.js
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/tests/xpcshell/test_client.js
--- a/services/common/hawkclient.js
+++ b/services/common/hawkclient.js
@@ -60,22 +60,28 @@ this.HawkClient.prototype = {
    *
    * @param restResponse
    *        A RESTResponse object from a RESTRequest
    *
    * @param errorString
    *        A string describing the error
    */
   _constructError: function(restResponse, errorString) {
-    return {
+    let errorObj = {
       error: errorString,
       message: restResponse.statusText,
       code: restResponse.status,
       errno: restResponse.status
     };
+    let retryAfter = restResponse.headers["retry-after"];
+    retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter;
+    if (retryAfter) {
+      errorObj.retryAfter = retryAfter;
+    }
+    return errorObj;
   },
 
   /*
    *
    * Update clock offset by determining difference from date gives in the (RFC
    * 1123) Date header of a server response.  Because HAWK tolerates a window
    * of one minute of clock skew (so two minutes total since the skew can be
    * positive or negative), the simple method of calculating offset here is
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -22,16 +22,20 @@ try {
 
 const HOST = _host;
 this.FxAccountsClient = function(host = HOST) {
   this.host = host;
 
   // The FxA auth server expects requests to certain endpoints to be authorized
   // using Hawk.
   this.hawk = new HawkClient(host);
+
+  // Manage server backoff state. C.f.
+  // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol
+  this.backoffError = null;
 };
 
 this.FxAccountsClient.prototype = {
 
   /**
    * Return client clock offset, in milliseconds, as determined by hawk client.
    * Provided because callers should not have to know about hawk
    * implementation.
@@ -301,16 +305,20 @@ this.FxAccountsClient.prototype = {
     return {
       algorithm: "sha256",
       key: out.slice(32, 64),
       extra: out.slice(64),
       id: CommonUtils.bytesAsHex(out.slice(0, 32))
     };
   },
 
+  _clearBackoff: function() {
+      this.backoffError = null;
+  },
+
   /**
    * A general method for sending raw API calls to the FxA auth server.
    * All request bodies and responses are JSON.
    *
    * @param path
    *        API endpoint path
    * @param method
    *        The HTTP request method
@@ -327,29 +335,47 @@ this.FxAccountsClient.prototype = {
    *          "error": "Bad Request", // string description of the error type
    *          "message": "the value of salt is not allowed to be undefined",
    *          "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
    *        }
    */
   _request: function hawkRequest(path, method, credentials, jsonPayload) {
     let deferred = Promise.defer();
 
+    // We were asked to back off.
+    if (this.backoffError) {
+      log.debug("Received new request during backoff, re-rejecting.");
+      deferred.reject(this.backoffError);
+      return deferred.promise;
+    }
+
     this.hawk.request(path, method, credentials, jsonPayload).then(
       (responseText) => {
         try {
           let response = JSON.parse(responseText);
           deferred.resolve(response);
         } catch (err) {
           log.error("json parse error on response: " + responseText);
           deferred.reject({error: err});
         }
       },
 
       (error) => {
         log.error("error " + method + "ing " + path + ": " + JSON.stringify(error));
+        if (error.retryAfter) {
+          log.debug("Received backoff response; caching error as flag.");
+          this.backoffError = error;
+          // Schedule clearing of cached-error-as-flag.
+          CommonUtils.namedTimer(
+            this._clearBackoff,
+            error.retryAfter * 1000,
+            this,
+            "fxaBackoffTimer"
+           );
+	}
         deferred.reject(error);
       }
     );
 
     return deferred.promise;
   },
 };
 
--- a/services/fxaccounts/tests/xpcshell/test_client.js
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -97,16 +97,63 @@ add_task(function test_500_error() {
   } catch (e) {
     do_check_eq(500, e.code);
     do_check_eq("Internal Server Error", e.message);
   }
 
   yield deferredStop(server);
 });
 
+add_task(function test_backoffError() {
+  let method = "GET";
+  let server = httpd_setup({
+    "/retryDelay": function(request, response) {
+      response.setHeader("Retry-After", "30");
+      response.setStatusLine(request.httpVersion, 429, "Client has sent too many requests");
+      let message = "<h1>Ooops!</h1>";
+      response.bodyOutputStream.write(message, message.length);
+    },
+    "/duringDelayIShouldNotBeCalled": function(request, response) {
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      let jsonMessage = "{\"working\": \"yes\"}";
+      response.bodyOutputStream.write(jsonMessage, jsonMessage.length);
+    },
+  });
+
+  let client = new FxAccountsClient(server.baseURI);
+
+  // Retry-After header sets client.backoffError
+  do_check_eq(client.backoffError, null);
+  try {
+    yield client._request("/retryDelay", method);
+  } catch (e) {
+    do_check_eq(429, e.code);
+    do_check_eq(30, e.retryAfter);
+    do_check_neq(typeof(client.fxaBackoffTimer), "undefined");
+    do_check_neq(client.backoffError, null);
+  }
+  // While delay is in effect, client short-circuits any requests
+  // and re-rejects with previous error.
+  try {
+    yield client._request("/duringDelayIShouldNotBeCalled", method);
+    throw new Error("I should not be reached");
+  } catch (e) {
+    do_check_eq(e.retryAfter, 30);
+    do_check_eq(e.message, "Client has sent too many requests");
+    do_check_neq(client.backoffError, null);
+  }
+  // Once timer fires, client nulls error out and HTTP calls work again.
+  client._clearBackoff();
+  let result = yield client._request("/duringDelayIShouldNotBeCalled", method);
+  do_check_eq(client.backoffError, null);
+  do_check_eq(result.working, "yes");
+
+  yield deferredStop(server);
+});
+
 add_task(function test_signUp() {
   let creationMessage = JSON.stringify({
     uid: "uid",
     sessionToken: "sessionToken",
     keyFetchToken: "keyFetchToken"
   });
   let errorMessage = JSON.stringify({code: 400, errno: 101, error: "account exists"});
   let created = false;