Backed out 2 changesets (bug 1439777) for xpcshell failures on /test_errorhandler_2.js
authorNarcis Beleuzu <nbeleuzu@mozilla.com>
Tue, 06 Mar 2018 04:43:41 +0200
changeset 459153 fea7978f3af6c940383e540d0605ed8456b2b827
parent 459152 614d32cc355566911aa905794a364c74c9585525
child 459207 490f87831c110acaa3eb966679ad94a1eaf51dd9
push id8817
push userarchaeopteryx@coole-files.de
push dateTue, 06 Mar 2018 14:33:14 +0000
treeherdermozilla-beta@af9d5a9c1e28 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1439777
milestone60.0a1
backs outd99402ad9ecc3a33a205889459c3ab5bdf0e0c22
1af3426dc95663649b4b0f228e12691bfea587c9
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Backed out 2 changesets (bug 1439777) for xpcshell failures on /test_errorhandler_2.js Backed out changeset d99402ad9ecc (bug 1439777) Backed out changeset 1af3426dc956 (bug 1439777)
browser/base/content/browser-sync.js
browser/components/customizableui/test/browser_remote_tabs_button.js
services/sync/modules/constants.js
services/sync/modules/policies.js
services/sync/modules/service.js
services/sync/modules/status.js
services/sync/services-sync.js
services/sync/tests/unit/test_errorhandler_1.js
services/sync/tests/unit/test_errorhandler_2.js
services/sync/tests/unit/test_errorhandler_eol.js
services/sync/tests/unit/test_errorhandler_filelog.js
services/sync/tests/unit/test_fxa_node_reassignment.js
services/sync/tests/unit/test_node_reassignment.js
services/sync/tests/unit/test_service_sync_updateEnabledEngines.js
services/sync/tests/unit/xpcshell.ini
services/sync/tps/extensions/tps/resource/tps.jsm
tools/lint/eslint/modules.json
--- a/browser/base/content/browser-sync.js
+++ b/browser/base/content/browser-sync.js
@@ -560,17 +560,17 @@ var gSync = {
   // via the various UI components.
   doSync() {
     if (!UIState.isReady()) {
       return;
     }
     const state = UIState.get();
     if (state.status == UIState.STATUS_SIGNED_IN) {
       this.updateSyncStatus({ syncing: true });
-      Services.tm.dispatchToMainThread(() => Weave.Service.sync());
+      setTimeout(() => Weave.Service.errorHandler.syncAndReportErrors(), 0);
     }
   },
 
   openPrefs(entryPoint = "syncbutton", origin = undefined) {
     window.openPreferences("paneSync", { origin, urlParams: { entrypoint: entryPoint } });
   },
 
   openSyncedTabsPanel() {
--- a/browser/components/customizableui/test/browser_remote_tabs_button.js
+++ b/browser/components/customizableui/test/browser_remote_tabs_button.js
@@ -64,24 +64,25 @@ add_task(async function asyncCleanup() {
 
 function mockFunctions() {
   // mock UIState.get()
   UIState.get = () => ({
     status: UIState.STATUS_SIGNED_IN,
     email: "user@mozilla.com"
   });
 
-  service.sync = mocked_syncAndReportErrors;
+  // mock service.errorHandler.syncAndReportErrors()
+  service.errorHandler.syncAndReportErrors = mocked_syncAndReportErrors;
 }
 
 function mocked_syncAndReportErrors() {
   syncWasCalled = true;
 }
 
 function restoreValues() {
   UIState.get = getState;
-  service.sync = originalSync;
+  service.syncAndReportErrors = originalSync;
 }
 
 function storeInitialValues() {
   getState = UIState.get;
-  originalSync = service.sync;
+  originalSync = service.syncAndReportErrors;
 }
--- a/services/sync/modules/constants.js
+++ b/services/sync/modules/constants.js
@@ -101,16 +101,17 @@ LOGIN_FAILED_LOGIN_REJECTED:           "
 
 // sync failure status codes
 METARECORD_DOWNLOAD_FAIL:              "error.sync.reason.metarecord_download_fail",
 VERSION_OUT_OF_DATE:                   "error.sync.reason.version_out_of_date",
 CREDENTIALS_CHANGED:                   "error.sync.reason.credentials_changed",
 ABORT_SYNC_COMMAND:                    "aborting sync, process commands said so",
 NO_SYNC_NODE_FOUND:                    "error.sync.reason.no_node_found",
 OVER_QUOTA:                            "error.sync.reason.over_quota",
+PROLONGED_SYNC_FAILURE:                "error.sync.prolonged_failure",
 SERVER_MAINTENANCE:                    "error.sync.reason.serverMaintenance",
 
 RESPONSE_OVER_QUOTA:                   "14",
 
 // engine failure status codes
 ENGINE_UPLOAD_FAIL:                    "error.engine.reason.record_upload_fail",
 ENGINE_DOWNLOAD_FAIL:                  "error.engine.reason.record_download_fail",
 ENGINE_UNKNOWN_FAIL:                   "error.engine.reason.unknown_fail",
--- a/services/sync/modules/policies.js
+++ b/services/sync/modules/policies.js
@@ -52,16 +52,17 @@ SyncScheduler.prototype = {
 
   setDefaults: function setDefaults() {
     this._log.trace("Setting SyncScheduler policy values to defaults.");
 
     this.singleDeviceInterval = getThrottledIntervalPreference("scheduler.fxa.singleDeviceInterval");
     this.idleInterval         = getThrottledIntervalPreference("scheduler.idleInterval");
     this.activeInterval       = getThrottledIntervalPreference("scheduler.activeInterval");
     this.immediateInterval    = getThrottledIntervalPreference("scheduler.immediateInterval");
+    this.eolInterval          = getThrottledIntervalPreference("scheduler.eolInterval");
 
     // A user is non-idle on startup by default.
     this.idle = false;
 
     this.hasIncomingItems = false;
     // This is the last number of clients we saw when previously updating the
     // client mode. If this != currentNumClients (obtained from prefs written
     // by the clients engine) then we need to transition to and from
@@ -400,16 +401,22 @@ SyncScheduler.prototype = {
           this._log.debug("Going to sleep, doing a quick sync.");
           this.scheduleNextSync(0, {engines: ["tabs"], why: "sleep"});
         }
         break;
     }
   },
 
   adjustSyncInterval: function adjustSyncInterval() {
+    if (Status.eol) {
+      this._log.debug("Server status is EOL; using eolInterval.");
+      this.syncInterval = this.eolInterval;
+      return;
+    }
+
     if (this.numClients <= 1) {
       this._log.trace("Adjusting syncInterval to singleDeviceInterval.");
       this.syncInterval = this.singleDeviceInterval;
       return;
     }
 
     // Only MULTI_DEVICE clients will enter this if statement
     // since SINGLE_USER clients will be handled above.
@@ -649,17 +656,31 @@ SyncScheduler.prototype = {
 
 };
 
 function ErrorHandler(service) {
   this.service = service;
   this.init();
 }
 ErrorHandler.prototype = {
-  init() {
+  MINIMUM_ALERT_INTERVAL_MSEC: 604800000,   // One week.
+
+  /**
+   * Flag that turns on error reporting for all errors, incl. network errors.
+   */
+  dontIgnoreErrors: false,
+
+  /**
+   * Flag that indicates if we have already reported a prolonged failure.
+   * Once set, we don't report it again, meaning this error is only reported
+   * one per run.
+   */
+  didReportProlongedError: false,
+
+  init: function init() {
     Svc.Obs.add("weave:engine:sync:applied", this);
     Svc.Obs.add("weave:engine:sync:error", this);
     Svc.Obs.add("weave:service:login:error", this);
     Svc.Obs.add("weave:service:sync:error", this);
     Svc.Obs.add("weave:service:sync:finish", this);
     Svc.Obs.add("weave:service:start-over:finish", this);
 
     this.initLogs();
@@ -675,17 +696,17 @@ ErrorHandler.prototype = {
 
     let logs = ["Sync", "Services.Common", "FirefoxAccounts", "Hawk",
                 "browserwindow.syncui", "BookmarkSyncUtils", "addons.xpi",
                ];
 
     this._logManager = new LogManager(Svc.Prefs, logs, "sync");
   },
 
-  observe(subject, topic, data) {
+  observe: function observe(subject, topic, data) {
     this._log.trace("Handling " + topic);
     switch (topic) {
       case "weave:engine:sync:applied":
         if (subject.newFailed) {
           // An engine isn't able to apply one or more incoming records.
           // We don't fail hard on this, but it usually indicates a bug,
           // so for now treat it as sync error (c.f. Service._syncEngine())
           Status.engines = [data, ENGINE_APPLY_FAIL];
@@ -706,16 +727,24 @@ ErrorHandler.prototype = {
           Services.telemetry.getKeyedHistogramById("WEAVE_ENGINE_SYNC_ERRORS")
                             .add(engine_name);
         }
         break;
       }
       case "weave:service:login:error":
         this._log.error("Sync encountered a login error");
         this.resetFileLog();
+
+        if (this.shouldReportError()) {
+          this.notifyOnNextTick("weave:ui:login:error");
+        } else {
+          this.notifyOnNextTick("weave:ui:clear-error");
+        }
+
+        this.dontIgnoreErrors = false;
         break;
       case "weave:service:sync:error": {
         if (Status.sync == CREDENTIALS_CHANGED) {
           this.service.logout();
         }
 
         let exception = subject;
         if (Async.isShutdownException(exception)) {
@@ -724,16 +753,24 @@ ErrorHandler.prototype = {
           this._log.error("Sync was interrupted due to the application shutting down");
           this.resetFileLog();
           break;
         }
 
         // Not a shutdown related exception...
         this._log.error("Sync encountered an error", exception);
         this.resetFileLog();
+
+        if (this.shouldReportError()) {
+          this.notifyOnNextTick("weave:ui:sync:error");
+        } else {
+          this.notifyOnNextTick("weave:ui:sync:finish");
+        }
+
+        this.dontIgnoreErrors = false;
         break;
       }
       case "weave:service:sync:finish":
         this._log.trace("Status.service is " + Status.service);
 
         // Check both of these status codes: in the event of a failure in one
         // engine, Status.service will be SYNC_FAILED_PARTIAL despite
         // Status.sync being SYNC_SUCCEEDED.
@@ -742,26 +779,57 @@ ErrorHandler.prototype = {
             Status.service == STATUS_OK) {
           // Great. Let's clear our mid-sync 401 note.
           this._log.trace("Clearing lastSyncReassigned.");
           Svc.Prefs.reset("lastSyncReassigned");
         }
 
         if (Status.service == SYNC_FAILED_PARTIAL) {
           this._log.error("Some engines did not sync correctly.");
+          this.resetFileLog();
+
+          if (this.shouldReportError()) {
+            this.dontIgnoreErrors = false;
+            this.notifyOnNextTick("weave:ui:sync:error");
+            break;
+          }
+        } else {
+          this.resetFileLog();
         }
-        this.resetFileLog();
+        this.dontIgnoreErrors = false;
+        this.notifyOnNextTick("weave:ui:sync:finish");
         break;
       case "weave:service:start-over:finish":
         // ensure we capture any logs between the last sync and the reset completing.
         this.resetFileLog();
         break;
     }
   },
 
+  notifyOnNextTick: function notifyOnNextTick(topic) {
+    CommonUtils.nextTick(function() {
+      this._log.trace("Notifying " + topic +
+                      ". Status.login is " + Status.login +
+                      ". Status.sync is " + Status.sync);
+      Svc.Obs.notify(topic);
+    }, this);
+  },
+
+  /**
+   * Trigger a sync and don't muffle any errors, particularly network errors.
+   */
+  syncAndReportErrors: function syncAndReportErrors() {
+    this._log.debug("Beginning user-triggered sync.");
+
+    this.dontIgnoreErrors = true;
+    CommonUtils.nextTick(() => {
+      this.service.sync({why: "user"});
+    }, this);
+  },
+
   async _dumpAddons() {
     // Just dump the items that sync may be concerned with. Specifically,
     // active extensions that are not hidden.
     let addons = [];
     try {
       addons = await AddonManager.getAddonsByTypes(["extension"]);
     } catch (e) {
       this._log.warn("Failed to dump addons", e);
@@ -773,37 +841,212 @@ ErrorHandler.prototype = {
       this._log.debug(" - ${name}, version ${version}, id ${id}", addon);
     }
   },
 
   /**
    * Generate a log file for the sync that just completed
    * and refresh the input & output streams.
    */
-  async resetFileLog() {
+  resetFileLog: function resetFileLog() {
+    let onComplete = logType => {
+      Svc.Obs.notify("weave:service:reset-file-log");
+      this._log.trace("Notified: " + Date.now());
+      if (logType == this._logManager.ERROR_LOG_WRITTEN) {
+        Cu.reportError("Sync encountered an error - see about:sync-log for the log file.");
+      }
+    };
+
     // If we're writing an error log, dump extensions that may be causing problems.
+    let beforeResetLog;
     if (this._logManager.sawError) {
-      await this._dumpAddons();
+      beforeResetLog = this._dumpAddons();
+    } else {
+      beforeResetLog = Promise.resolve();
+    }
+    // Note we do not return the promise here - the caller doesn't need to wait
+    // for this to complete.
+    beforeResetLog
+      .then(() => this._logManager.resetFileLog())
+      .then(onComplete, onComplete);
+  },
+
+  /**
+   * Translates server error codes to meaningful strings.
+   *
+   * @param code
+   *        server error code as an integer
+   */
+  errorStr: function errorStr(code) {
+    switch (code.toString()) {
+    case "1":
+      return "illegal-method";
+    case "2":
+      return "invalid-captcha";
+    case "3":
+      return "invalid-username";
+    case "4":
+      return "cannot-overwrite-resource";
+    case "5":
+      return "userid-mismatch";
+    case "6":
+      return "json-parse-failure";
+    case "7":
+      return "invalid-password";
+    case "8":
+      return "invalid-record";
+    case "9":
+      return "weak-password";
+    default:
+      return "generic-server-error";
+    }
+  },
+
+  // A function to indicate if Sync errors should be "reported" - which in this
+  // context really means "should be notify observers of an error" - but note
+  // that since bug 1180587, no one is going to surface an error to the user.
+  shouldReportError: function shouldReportError() {
+    if (Status.login == MASTER_PASSWORD_LOCKED) {
+      this._log.trace("shouldReportError: false (master password locked).");
+      return false;
+    }
+
+    if (this.dontIgnoreErrors) {
+      return true;
+    }
+
+    if (Status.login == LOGIN_FAILED_LOGIN_REJECTED) {
+      // An explicit LOGIN_REJECTED state is always reported (bug 1081158)
+      this._log.trace("shouldReportError: true (login was rejected)");
+      return true;
     }
-    const logType = await this._logManager.resetFileLog();
-    if (logType == this._logManager.ERROR_LOG_WRITTEN) {
-      Cu.reportError("Sync encountered an error - see about:sync-log for the log file.");
+
+    let lastSync = Svc.Prefs.get("lastSync");
+    if (lastSync && ((Date.now() - Date.parse(lastSync)) >
+        Svc.Prefs.get("errorhandler.networkFailureReportTimeout") * 1000)) {
+      Status.sync = PROLONGED_SYNC_FAILURE;
+      if (this.didReportProlongedError) {
+        this._log.trace("shouldReportError: false (prolonged sync failure, but" +
+                        " we've already reported it).");
+        return false;
+      }
+      this._log.trace("shouldReportError: true (first prolonged sync failure).");
+      this.didReportProlongedError = true;
+      return true;
+    }
+
+    // We got a 401 mid-sync. Wait for the next sync before actually handling
+    // an error. This assumes that we'll get a 401 again on a login fetch in
+    // order to report the error.
+    if (!this.service.clusterURL) {
+      this._log.trace("shouldReportError: false (no cluster URL; " +
+                      "possible node reassignment).");
+      return false;
     }
-    Svc.Obs.notify("weave:service:reset-file-log");
+
+
+    let result = (![Status.login, Status.sync].includes(SERVER_MAINTENANCE) &&
+                  ![Status.login, Status.sync].includes(LOGIN_FAILED_NETWORK_ERROR));
+    this._log.trace("shouldReportError: ${result} due to login=${login}, sync=${sync}",
+                    {result, login: Status.login, sync: Status.sync});
+    return result;
+  },
+
+  get currentAlertMode() {
+    return Svc.Prefs.get("errorhandler.alert.mode");
+  },
+
+  set currentAlertMode(str) {
+    return Svc.Prefs.set("errorhandler.alert.mode", str);
+  },
+
+  get earliestNextAlert() {
+    return Svc.Prefs.get("errorhandler.alert.earliestNext", 0) * 1000;
+  },
+
+  set earliestNextAlert(msec) {
+    return Svc.Prefs.set("errorhandler.alert.earliestNext", msec / 1000);
+  },
+
+  clearServerAlerts() {
+    // If we have any outstanding alerts, apparently they're no longer relevant.
+    Svc.Prefs.resetBranch("errorhandler.alert");
+  },
+
+  /**
+   * X-Weave-Alert headers can include a JSON object:
+   *
+   *   {
+   *    "code":    // One of "hard-eol", "soft-eol".
+   *    "url":     // For "Learn more" link.
+   *    "message": // Logged in Sync logs.
+   *   }
+   */
+  handleServerAlert(xwa) {
+    if (!xwa.code) {
+      this._log.warn("Got structured X-Weave-Alert, but no alert code.");
+      return;
+    }
+
+    switch (xwa.code) {
+      // Gently and occasionally notify the user that this service will be
+      // shutting down.
+      case "soft-eol":
+        // Fall through.
+
+      // Tell the user that this service has shut down, and drop our syncing
+      // frequency dramatically.
+      case "hard-eol":
+        // Note that both of these alerts should be subservient to future "sign
+        // in with your Firefox Account" storage alerts.
+        if ((this.currentAlertMode != xwa.code) ||
+            (this.earliestNextAlert < Date.now())) {
+          CommonUtils.nextTick(function() {
+            Svc.Obs.notify("weave:eol", xwa);
+          }, this);
+          this._log.error("X-Weave-Alert: " + xwa.code + ": " + xwa.message);
+          this.earliestNextAlert = Date.now() + this.MINIMUM_ALERT_INTERVAL_MSEC;
+          this.currentAlertMode = xwa.code;
+        }
+        break;
+      default:
+        this._log.debug("Got unexpected X-Weave-Alert code: " + xwa.code);
+    }
   },
 
   /**
    * Handle HTTP response results or exceptions and set the appropriate
    * Status.* bits.
    *
    * This method also looks for "side-channel" warnings.
    */
   checkServerError(resp) {
     // In this case we were passed a resolved value of Resource#_doRequest.
     switch (resp.status) {
+      case 200:
+      case 404:
+      case 513:
+        let xwa = resp.headers["x-weave-alert"];
+
+        // Only process machine-readable alerts.
+        if (!xwa || !xwa.startsWith("{")) {
+          this.clearServerAlerts();
+          return;
+        }
+
+        try {
+          xwa = JSON.parse(xwa);
+        } catch (ex) {
+          this._log.warn("Malformed X-Weave-Alert from server: " + xwa);
+          return;
+        }
+
+        this.handleServerAlert(xwa);
+        break;
+
       case 400:
         if (resp == RESPONSE_OVER_QUOTA) {
           Status.sync = OVER_QUOTA;
         }
         break;
 
       case 401:
         this.service.logout();
--- a/services/sync/modules/service.js
+++ b/services/sync/modules/service.js
@@ -515,17 +515,17 @@ Sync11Service.prototype = {
     let info;
     try {
       info = await this.resource(infoURL).get();
     } catch (ex) {
       this.errorHandler.checkServerError(ex);
       throw ex;
     }
 
-    // Always check for errors.
+    // Always check for errors; this is also where we look for X-Weave-Alert.
     this.errorHandler.checkServerError(info);
     if (!info.success) {
       this._log.error("Aborting sync: failed to get collections.");
       throw info;
     }
     return info;
   },
 
--- a/services/sync/modules/status.js
+++ b/services/sync/modules/status.js
@@ -54,16 +54,25 @@ var Status = {
   },
 
   set sync(code) {
     this._log.debug("Status.sync: " + this._sync + " => " + code);
     this._sync = code;
     this.service = code == SYNC_SUCCEEDED ? STATUS_OK : SYNC_FAILED;
   },
 
+  get eol() {
+    let modePref = PREFS_BRANCH + "errorhandler.alert.mode";
+    try {
+      return Services.prefs.getCharPref(modePref) == "hard-eol";
+    } catch (ex) {
+      return false;
+    }
+  },
+
   get engines() {
     return this._engines;
   },
 
   set engines([name, code]) {
     this._log.debug("Status for engine " + name + ": " + code);
     this._engines[name] = code;
 
--- a/services/sync/services-sync.js
+++ b/services/sync/services-sync.js
@@ -2,23 +2,26 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global pref */
 
 pref("services.sync.lastversion", "firstrun");
 pref("services.sync.sendVersionInfo", true);
 
+pref("services.sync.scheduler.eolInterval", 604800); // 1 week
 pref("services.sync.scheduler.idleInterval", 3600);  // 1 hour
 pref("services.sync.scheduler.activeInterval", 600);   // 10 minutes
 pref("services.sync.scheduler.immediateInterval", 90);    // 1.5 minutes
 pref("services.sync.scheduler.idleTime", 300);   // 5 minutes
 
 pref("services.sync.scheduler.fxa.singleDeviceInterval", 3600); // 1 hour
 
+pref("services.sync.errorhandler.networkFailureReportTimeout", 1209600); // 2 weeks
+
 // Note that new engines are typically added with a default of disabled, so
 // when an existing sync user gets the Firefox upgrade that supports the engine
 // it starts as disabled until the user has explicitly opted in.
 // The sync "create account" process typically *will* offer these engines, so
 // they may be flipped to enabled at that time.
 pref("services.sync.engine.addons", true);
 pref("services.sync.engine.addresses", false);
 pref("services.sync.engine.bookmarks", true);
--- a/services/sync/tests/unit/test_errorhandler_1.js
+++ b/services/sync/tests/unit/test_errorhandler_1.js
@@ -7,39 +7,57 @@ ChromeUtils.import("resource://services-
 ChromeUtils.import("resource://services-sync/keys.js");
 ChromeUtils.import("resource://services-sync/policies.js");
 ChromeUtils.import("resource://services-sync/service.js");
 ChromeUtils.import("resource://services-sync/status.js");
 ChromeUtils.import("resource://services-sync/util.js");
 ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
 ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm");
 
-const fakeServer = new SyncServer();
+var fakeServer = new SyncServer();
 fakeServer.start();
-const fakeServerUrl = "http://localhost:" + fakeServer.port;
 
 registerCleanupFunction(function() {
   return promiseStopServer(fakeServer).finally(() => {
     Svc.Prefs.resetBranch("");
   });
 });
 
+var fakeServerUrl = "http://localhost:" + fakeServer.port;
+
+const logsdir = FileUtils.getDir("ProfD", ["weave", "logs"], true);
+
+const PROLONGED_ERROR_DURATION =
+  (Svc.Prefs.get("errorhandler.networkFailureReportTimeout") * 2) * 1000;
+
+const NON_PROLONGED_ERROR_DURATION =
+  (Svc.Prefs.get("errorhandler.networkFailureReportTimeout") / 2) * 1000;
+
+function setLastSync(lastSyncValue) {
+  Svc.Prefs.set("lastSync", (new Date(Date.now() - lastSyncValue)).toString());
+}
+
+// This relies on Service/ErrorHandler being a singleton. Fixing this will take
+// a lot of work.
+let errorHandler = Service.errorHandler;
 let engine;
+
 add_task(async function setup() {
   await Service.engineManager.clear();
   await Service.engineManager.register(EHTestsCommon.CatapultEngine);
   engine = Service.engineManager.get("catapult");
 });
 
 async function clean() {
   let promiseLogReset = promiseOneObserver("weave:service:reset-file-log");
   await Service.startOver();
   await promiseLogReset;
   Status.resetSync();
   Status.resetBackoff();
+  errorHandler.didReportProlongedError = false;
   // Move log levels back to trace (startOver will have reversed this), sicne
   syncTestLogging();
 }
 
 add_task(async function test_401_logout() {
   enableValidationPrefs();
 
   let server = await EHTestsCommon.sync_httpd_setup();
@@ -107,98 +125,525 @@ add_task(async function test_credentials
   Assert.equal(Status.sync, CREDENTIALS_CHANGED);
   Assert.ok(!Service.isLoggedIn);
 
   // Clean up.
   await Service.startOver();
   await promiseStopServer(server);
 });
 
-add_task(async function test_login_non_network_error() {
+add_task(function test_no_lastSync_pref() {
+  syncTestLogging();
+  // Test reported error.
+  Status.resetSync();
+  errorHandler.dontIgnoreErrors = true;
+  Status.sync = CREDENTIALS_CHANGED;
+  Assert.ok(errorHandler.shouldReportError());
+
+  // Test unreported error.
+  Status.resetSync();
+  errorHandler.dontIgnoreErrors = true;
+  Status.login = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(errorHandler.shouldReportError());
+
+});
+
+add_task(function test_shouldReportError() {
+  Status.login = MASTER_PASSWORD_LOCKED;
+  Assert.ok(!errorHandler.shouldReportError());
+
+  // Give ourselves a clusterURL so that the temporary 401 no-error situation
+  // doesn't come into play.
+  Service.clusterURL = fakeServerUrl;
+
+  // Test dontIgnoreErrors, non-network, non-prolonged, sync error reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.sync = CREDENTIALS_CHANGED;
+  Assert.ok(errorHandler.shouldReportError());
+
+  // Test dontIgnoreErrors, non-network, prolonged, sync error reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.sync = CREDENTIALS_CHANGED;
+  Assert.ok(errorHandler.shouldReportError());
+
+  // Test dontIgnoreErrors, network, non-prolonged, login error reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.login = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(errorHandler.shouldReportError());
+
+  // Test dontIgnoreErrors, network, non-prolonged, sync error reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.sync = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(errorHandler.shouldReportError());
+
+  // Test dontIgnoreErrors, network, prolonged, login error reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.login = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(errorHandler.shouldReportError());
+
+  // Test dontIgnoreErrors, network, prolonged, sync error reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.sync = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(errorHandler.shouldReportError());
+
+  // Test non-network, prolonged, sync error reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  errorHandler.didReportProlongedError = false;
+  Status.sync = CREDENTIALS_CHANGED;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(errorHandler.didReportProlongedError);
+  errorHandler.didReportProlongedError = false;
+
+  // Test network, prolonged, login error reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.login = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(errorHandler.didReportProlongedError);
+  errorHandler.didReportProlongedError = false;
+
+  // Test network, prolonged, sync error reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.sync = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(errorHandler.didReportProlongedError);
+  errorHandler.didReportProlongedError = false;
+
+  // Test non-network, non-prolonged, sync error reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.sync = CREDENTIALS_CHANGED;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  // Test network, non-prolonged, login error reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.login = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(!errorHandler.shouldReportError());
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  // Test network, non-prolonged, sync error reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.sync = LOGIN_FAILED_NETWORK_ERROR;
+  Assert.ok(!errorHandler.shouldReportError());
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  // Test server maintenance, sync errors are not reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.sync = SERVER_MAINTENANCE;
+  Assert.ok(!errorHandler.shouldReportError());
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  // Test server maintenance, login errors are not reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.login = SERVER_MAINTENANCE;
+  Assert.ok(!errorHandler.shouldReportError());
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  // Test prolonged, server maintenance, sync errors are reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.sync = SERVER_MAINTENANCE;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(errorHandler.didReportProlongedError);
+  errorHandler.didReportProlongedError = false;
+
+  // Test prolonged, server maintenance, login errors are reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = false;
+  Status.login = SERVER_MAINTENANCE;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(errorHandler.didReportProlongedError);
+  errorHandler.didReportProlongedError = false;
+
+  // Test dontIgnoreErrors, server maintenance, sync errors are reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.sync = SERVER_MAINTENANCE;
+  Assert.ok(errorHandler.shouldReportError());
+  // dontIgnoreErrors means we don't set didReportProlongedError
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  // Test dontIgnoreErrors, server maintenance, login errors are reported
+  Status.resetSync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.login = SERVER_MAINTENANCE;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  // Test dontIgnoreErrors, prolonged, server maintenance,
+  // sync errors are reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.sync = SERVER_MAINTENANCE;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  // Test dontIgnoreErrors, prolonged, server maintenance,
+  // login errors are reported
+  Status.resetSync();
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.dontIgnoreErrors = true;
+  Status.login = SERVER_MAINTENANCE;
+  Assert.ok(errorHandler.shouldReportError());
+  Assert.ok(!errorHandler.didReportProlongedError);
+});
+
+add_task(async function test_shouldReportError_master_password() {
+  _("Test error ignored due to locked master password");
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  // Monkey patch Service.verifyLogin to imitate
+  // master password being locked.
+  Service._verifyLogin = Service.verifyLogin;
+  Service.verifyLogin = async function() {
+    Status.login = MASTER_PASSWORD_LOCKED;
+    return false;
+  };
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await Service.sync();
+  Assert.ok(!errorHandler.shouldReportError());
+
+  // Clean up.
+  Service.verifyLogin = Service._verifyLogin;
+  await clean();
+  await promiseStopServer(server);
+});
+
+// Test that even if we don't have a cluster URL, a login failure due to
+// authentication errors is always reported.
+add_task(function test_shouldReportLoginFailureWithNoCluster() {
+  // Ensure no clusterURL - any error not specific to login should not be reported.
+  Service.clusterURL = "";
+
+  // Test explicit "login rejected" state.
+  Status.resetSync();
+  // If we have a LOGIN_REJECTED state, we always report the error.
+  Status.login = LOGIN_FAILED_LOGIN_REJECTED;
+  Assert.ok(errorHandler.shouldReportError());
+  // But any other status with a missing clusterURL is treated as a mid-sync
+  // 401 (ie, should be treated as a node reassignment)
+  Status.login = LOGIN_SUCCEEDED;
+  Assert.ok(!errorHandler.shouldReportError());
+});
+
+add_task(async function test_login_syncAndReportErrors_non_network_error() {
   enableValidationPrefs();
 
   // Test non-network errors are reported
-  // when calling sync
+  // when calling syncAndReportErrors
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
   Service.identity._syncKeyBundle = null;
 
-  await Service.sync();
+  let promiseObserved = promiseOneObserver("weave:ui:login:error");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.syncAndReportErrors();
+  await promiseObserved;
   Assert.equal(Status.login, LOGIN_FAILED_NO_PASSPHRASE);
 
   await clean();
   await promiseStopServer(server);
 });
 
-add_task(async function test_sync_non_network_error() {
+add_task(async function test_sync_syncAndReportErrors_non_network_error() {
   enableValidationPrefs();
 
   // Test non-network errors are reported
-  // when calling sync
+  // when calling syncAndReportErrors
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   // By calling sync, we ensure we're logged in.
   await Service.sync();
   Assert.equal(Status.sync, SYNC_SUCCEEDED);
   Assert.ok(Service.isLoggedIn);
 
   await EHTestsCommon.generateCredentialsChangedFailure();
 
-  let ping = await sync_and_validate_telem(true);
+  let promiseObserved = promiseOneObserver("weave:ui:sync:error");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  let ping = await wait_for_ping(() => errorHandler.syncAndReportErrors(), true);
   equal(ping.status.sync, CREDENTIALS_CHANGED);
   deepEqual(ping.failureReason, {
     name: "unexpectederror",
     error: "Error: Aborting sync, remote setup failed"
   });
+  await promiseObserved;
+
+  Assert.equal(Status.sync, CREDENTIALS_CHANGED);
+  // If we clean this tick, telemetry won't get the right error
+  await Async.promiseYield();
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_login_syncAndReportErrors_prolonged_non_network_error() {
+  enableValidationPrefs();
+
+  // Test prolonged, non-network errors are
+  // reported when calling syncAndReportErrors.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+  Service.identity._syncKeyBundle = null;
+
+  let promiseObserved = promiseOneObserver("weave:ui:login:error");
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.syncAndReportErrors();
+  await promiseObserved;
+  Assert.equal(Status.login, LOGIN_FAILED_NO_PASSPHRASE);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_sync_syncAndReportErrors_prolonged_non_network_error() {
+  enableValidationPrefs();
+
+  // Test prolonged, non-network errors are
+  // reported when calling syncAndReportErrors.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  // By calling sync, we ensure we're logged in.
+  await Service.sync();
+  Assert.equal(Status.sync, SYNC_SUCCEEDED);
+  Assert.ok(Service.isLoggedIn);
+
+  await EHTestsCommon.generateCredentialsChangedFailure();
+
+  let promiseObserved = promiseOneObserver("weave:ui:sync:error");
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  let ping = await wait_for_ping(() => errorHandler.syncAndReportErrors(), true);
+  equal(ping.status.sync, CREDENTIALS_CHANGED);
+  deepEqual(ping.failureReason, {
+    name: "unexpectederror",
+    error: "Error: Aborting sync, remote setup failed"
+  });
+  await promiseObserved;
 
   Assert.equal(Status.sync, CREDENTIALS_CHANGED);
   // If we clean this tick, telemetry won't get the right error
   await Async.promiseYield();
   await clean();
   await promiseStopServer(server);
 });
 
-add_task(async function test_login_sync_network_error() {
+add_task(async function test_login_syncAndReportErrors_network_error() {
   enableValidationPrefs();
 
-  // Test network errors are reported when calling sync.
+  // Test network errors are reported when calling syncAndReportErrors.
   await configureIdentity({username: "broken.wipe"});
   Service.clusterURL = fakeServerUrl;
 
-  await Service.sync();
+  let promiseObserved = promiseOneObserver("weave:ui:login:error");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.syncAndReportErrors();
+  await promiseObserved;
+
   Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR);
 
   await clean();
 });
 
 
-add_task(async function test_sync_network_error() {
+add_task(async function test_sync_syncAndReportErrors_network_error() {
+  enableValidationPrefs();
+
+  // Test network errors are reported when calling syncAndReportErrors.
+  Services.io.offline = true;
+
+  let promiseUISyncError = promiseOneObserver("weave:ui:sync:error");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  errorHandler.syncAndReportErrors();
+  await promiseUISyncError;
+  Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR);
+
+  Services.io.offline = false;
+  await clean();
+});
+
+add_task(async function test_login_syncAndReportErrors_prolonged_network_error() {
+  enableValidationPrefs();
+
+  // Test prolonged, network errors are reported
+  // when calling syncAndReportErrors.
+  await configureIdentity({username: "johndoe"});
+
+  Service.clusterURL = fakeServerUrl;
+
+  let promiseObserved = promiseOneObserver("weave:ui:login:error");
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.syncAndReportErrors();
+  await promiseObserved;
+  Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR);
+
+  await clean();
+});
+
+add_task(async function test_sync_syncAndReportErrors_prolonged_network_error() {
+  enableValidationPrefs();
+
+  // Test prolonged, network errors are reported
+  // when calling syncAndReportErrors.
+  Services.io.offline = true;
+
+  let promiseUISyncError = promiseOneObserver("weave:ui:sync:error");
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  errorHandler.syncAndReportErrors();
+  await promiseUISyncError;
+  Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR);
+
+  Services.io.offline = false;
+  await clean();
+});
+
+add_task(async function test_login_prolonged_non_network_error() {
   enableValidationPrefs();
 
-  // Test network errors are reported when calling sync.
+  // Test prolonged, non-network errors are reported
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+  Service.identity._syncKeyBundle = null;
+
+  let promiseObserved = promiseOneObserver("weave:ui:login:error");
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await Service.sync();
+  await promiseObserved;
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_sync_prolonged_non_network_error() {
+  enableValidationPrefs();
+
+  // Test prolonged, non-network errors are reported
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  // By calling sync, we ensure we're logged in.
+  await Service.sync();
+  Assert.equal(Status.sync, SYNC_SUCCEEDED);
+  Assert.ok(Service.isLoggedIn);
+
+  await EHTestsCommon.generateCredentialsChangedFailure();
+
+  let promiseObserved = promiseOneObserver("weave:ui:sync:error");
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+
+  let ping = await sync_and_validate_telem(true);
+  equal(ping.status.sync, PROLONGED_SYNC_FAILURE);
+  deepEqual(ping.failureReason, {
+    name: "unexpectederror",
+    error: "Error: Aborting sync, remote setup failed"
+  });
+  await promiseObserved;
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_login_prolonged_network_error() {
+  enableValidationPrefs();
+
+  // Test prolonged, network errors are reported
+  await configureIdentity({username: "johndoe"});
+  Service.clusterURL = fakeServerUrl;
+
+  let promiseObserved = promiseOneObserver("weave:ui:login:error");
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await Service.sync();
+  await promiseObserved;
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
+
+  await clean();
+});
+
+add_task(async function test_sync_prolonged_network_error() {
+  enableValidationPrefs();
+
+  // Test prolonged, network errors are reported
   Services.io.offline = true;
 
+  let promiseUISyncError = promiseOneObserver("weave:ui:sync:error");
+
+  setLastSync(PROLONGED_ERROR_DURATION);
   await Service.sync();
-  Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR);
+  await promiseUISyncError;
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
 
   Services.io.offline = false;
   await clean();
 });
 
 add_task(async function test_login_non_network_error() {
   enableValidationPrefs();
 
   // Test non-network errors are reported
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
   Service.identity._syncKeyBundle = null;
 
+  let promiseObserved = promiseOneObserver("weave:ui:login:error");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
   await Service.sync();
+  await promiseObserved;
   Assert.equal(Status.login, LOGIN_FAILED_NO_PASSPHRASE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_non_network_error() {
   enableValidationPrefs();
 
@@ -208,46 +653,60 @@ add_task(async function test_sync_non_ne
 
   // By calling sync, we ensure we're logged in.
   await Service.sync();
   Assert.equal(Status.sync, SYNC_SUCCEEDED);
   Assert.ok(Service.isLoggedIn);
 
   await EHTestsCommon.generateCredentialsChangedFailure();
 
+  let promiseObserved = promiseOneObserver("weave:ui:sync:error");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
   await Service.sync();
+  await promiseObserved;
   Assert.equal(Status.sync, CREDENTIALS_CHANGED);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_login_network_error() {
   enableValidationPrefs();
 
   await configureIdentity({username: "johndoe"});
   Service.clusterURL = fakeServerUrl;
 
+  let promiseObserved = promiseOneObserver("weave:ui:clear-error");
   // Test network errors are not reported.
 
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
   await Service.sync();
+  await promiseObserved;
   Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   Services.io.offline = false;
   await clean();
 });
 
 add_task(async function test_sync_network_error() {
   enableValidationPrefs();
 
   // Test network errors are not reported.
   Services.io.offline = true;
 
+  let promiseSyncFinished = promiseOneObserver("weave:ui:sync:finish");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
   await Service.sync();
+  await promiseSyncFinished;
   Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   Services.io.offline = false;
   await clean();
 });
 
 add_task(async function test_sync_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -255,24 +714,34 @@ add_task(async function test_sync_server
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   const BACKOFF = 42;
   engine.enabled = true;
   engine.exception = {status: 503,
                       headers: {"retry-after": BACKOFF}};
 
+  function onSyncError() {
+    do_throw("Shouldn't get here!");
+  }
+  Svc.Obs.add("weave:ui:sync:error", onSyncError);
+
   Assert.equal(Status.service, STATUS_OK);
 
+  let promiseObserved = promiseOneObserver("weave:ui:sync:finish");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
   let ping = await sync_and_validate_telem(true);
   equal(ping.status.sync, SERVER_MAINTENANCE);
   deepEqual(ping.engines.find(e => e.failureReason).failureReason, { name: "httperror", code: 503 });
 
+  await promiseObserved;
   Assert.equal(Status.service, SYNC_FAILED_PARTIAL);
   Assert.equal(Status.sync, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_info_collections_login_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -283,26 +752,37 @@ add_task(async function test_info_collec
   await configureIdentity({username: "broken.info"}, server);
 
   let backoffInterval;
   Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
+  function onUIUpdate() {
+    do_throw("Shouldn't experience UI update!");
+  }
+  Svc.Obs.add("weave:ui:login:error", onUIUpdate);
+
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
+  let promiseObserved = promiseOneObserver("weave:ui:clear-error");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
   await Service.sync();
+  await promiseObserved;
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, LOGIN_FAILED);
   Assert.equal(Status.login, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
+  Svc.Obs.remove("weave:ui:login:error", onUIUpdate);
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_meta_global_login_server_maintenance_error() {
   enableValidationPrefs();
 
   // Test meta/global server maintenance errors are not reported.
@@ -312,21 +792,32 @@ add_task(async function test_meta_global
   await configureIdentity({username: "broken.meta"}, server);
 
   let backoffInterval;
   Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
+  function onUIUpdate() {
+    do_throw("Shouldn't get here!");
+  }
+  Svc.Obs.add("weave:ui:login:error", onUIUpdate);
+
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
+  let promiseObserved = promiseOneObserver("weave:ui:clear-error");
+
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
   await Service.sync();
+  await promiseObserved;
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, LOGIN_FAILED);
   Assert.equal(Status.login, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
+  Svc.Obs.remove("weave:ui:login:error", onUIUpdate);
   await clean();
   await promiseStopServer(server);
 });
--- a/services/sync/tests/unit/test_errorhandler_2.js
+++ b/services/sync/tests/unit/test_errorhandler_2.js
@@ -7,26 +7,25 @@ ChromeUtils.import("resource://services-
 ChromeUtils.import("resource://services-sync/keys.js");
 ChromeUtils.import("resource://services-sync/policies.js");
 ChromeUtils.import("resource://services-sync/service.js");
 ChromeUtils.import("resource://services-sync/status.js");
 ChromeUtils.import("resource://services-sync/util.js");
 ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
 ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm");
 
-const fakeServer = new SyncServer();
+var fakeServer = new SyncServer();
 fakeServer.start();
-const fakeServerUrl = "http://localhost:" + fakeServer.port;
 
 registerCleanupFunction(function() {
-  return promiseStopServer(fakeServer).finally(() => {
-    Svc.Prefs.resetBranch("");
-  });
+  return promiseStopServer(fakeServer);
 });
 
+var fakeServerUrl = "http://localhost:" + fakeServer.port;
+
 const logsdir = FileUtils.getDir("ProfD", ["weave", "logs"], true);
 
 function removeLogFiles() {
   let entries = logsdir.directoryEntries;
   while (entries.hasMoreElements()) {
     let logfile = entries.getNext().QueryInterface(Ci.nsIFile);
     logfile.remove(false);
   }
@@ -36,29 +35,61 @@ function getLogFiles() {
   let result = [];
   let entries = logsdir.directoryEntries;
   while (entries.hasMoreElements()) {
     result.push(entries.getNext().QueryInterface(Ci.nsIFile));
   }
   return result;
 }
 
+const PROLONGED_ERROR_DURATION =
+  (Svc.Prefs.get("errorhandler.networkFailureReportTimeout") * 2) * 1000;
+
+const NON_PROLONGED_ERROR_DURATION =
+  (Svc.Prefs.get("errorhandler.networkFailureReportTimeout") / 2) * 1000;
+
+function setLastSync(lastSyncValue) {
+  Svc.Prefs.set("lastSync", (new Date(Date.now() - lastSyncValue)).toString());
+}
+
+// This relies on Service/ErrorHandler being a singleton. Fixing this will take
+// a lot of work.
+var errorHandler = Service.errorHandler;
 let engine;
+
+async function syncAndWait(topic) {
+  let promise1 = promiseOneObserver(topic);
+  // also wait for the log file to be written
+  let promise2 = promiseOneObserver("weave:service:reset-file-log");
+  await Service.sync();
+  await promise1;
+  await promise2;
+}
+
+async function syncAndReportErrorsAndWait(topic) {
+  let promise1 = promiseOneObserver(topic);
+  // also wait for the log file to be written
+  let promise2 = promiseOneObserver("weave:service:reset-file-log");
+  errorHandler.syncAndReportErrors();
+  await promise1;
+  await promise2;
+}
 add_task(async function setup() {
   await Service.engineManager.clear();
   await Service.engineManager.register(EHTestsCommon.CatapultEngine);
   engine = Service.engineManager.get("catapult");
 });
 
 async function clean() {
   let promiseLogReset = promiseOneObserver("weave:service:reset-file-log");
   await Service.startOver();
   await promiseLogReset;
   Status.resetSync();
   Status.resetBackoff();
+  errorHandler.didReportProlongedError = false;
   removeLogFiles();
   // Move log levels back to trace (startOver will have reversed this), sicne
   syncTestLogging();
 }
 
 add_task(async function test_crypto_keys_login_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -73,26 +104,34 @@ add_task(async function test_crypto_keys
   Service.collectionKeys.clear();
 
   let backoffInterval;
   Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
+  function onUIUpdate() {
+    do_throw("Shouldn't get here!");
+  }
+  Svc.Obs.add("weave:ui:login:error", onUIUpdate);
+
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
-  await Service.sync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await syncAndWait("weave:ui:clear-error");
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, LOGIN_FAILED);
   Assert.equal(Status.login, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
+  Svc.Obs.remove("weave:ui:login:error", onUIUpdate);
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_lastSync_not_updated_on_complete_failure() {
   enableValidationPrefs();
 
   // Test info/collections prolonged server maintenance errors are reported.
@@ -122,35 +161,235 @@ add_task(async function test_lastSync_no
 
   // We shouldn't update lastSync on complete failure.
   Assert.equal(lastSync, Svc.Prefs.get("lastSync"));
 
   await clean();
   await promiseStopServer(server);
 });
 
+add_task(async function test_info_collections_login_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test info/collections prolonged server maintenance errors are reported.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  await configureIdentity({username: "broken.info"}, server);
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, SYNC_FAILED);
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_meta_global_login_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test meta/global prolonged server maintenance errors are reported.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  await configureIdentity({username: "broken.meta"}, server);
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, SYNC_FAILED);
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_download_crypto_keys_login_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test crypto/keys prolonged server maintenance errors are reported.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  await configureIdentity({username: "broken.keys"}, server);
+  // Force re-download of keys
+  Service.collectionKeys.clear();
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndWait("weave:ui:login:error");
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, SYNC_FAILED);
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_upload_crypto_keys_login_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test crypto/keys prolonged server maintenance errors are reported.
+  let server = await EHTestsCommon.sync_httpd_setup();
+
+  // Start off with an empty account, do not upload a key.
+  await configureIdentity({username: "broken.keys"}, server);
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, SYNC_FAILED);
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_wipeServer_login_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test that we report prolonged server maintenance errors that occur whilst
+  // wiping the server.
+  let server = await EHTestsCommon.sync_httpd_setup();
+
+  // Start off with an empty account, do not upload a key.
+  await configureIdentity({username: "broken.wipe"}, server);
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, SYNC_FAILED);
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.ok(errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_wipeRemote_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test that we report prolonged server maintenance errors that occur whilst
+  // wiping all remote devices.
+  let server = await EHTestsCommon.sync_httpd_setup();
+
+  server.registerPathHandler("/1.1/broken.wipe/storage/catapult", EHTestsCommon.service_unavailable);
+  await configureIdentity({username: "broken.wipe"}, server);
+  await EHTestsCommon.generateAndUploadKeys();
+
+  engine.exception = null;
+  engine.enabled = true;
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  let promiseObserved = promiseOneObserver("weave:ui:sync:error");
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  Svc.Prefs.set("firstSync", "wipeRemote");
+  setLastSync(PROLONGED_ERROR_DURATION);
+  let ping = await sync_and_validate_telem(true);
+  deepEqual(ping.failureReason, { name: "httperror", code: 503 });
+  await promiseObserved;
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, SYNC_FAILED);
+  Assert.equal(Status.sync, PROLONGED_SYNC_FAILURE);
+  Assert.equal(Svc.Prefs.get("firstSync"), "wipeRemote");
+  Assert.ok(errorHandler.didReportProlongedError);
+  await promiseStopServer(server);
+  await clean();
+});
+
 add_task(async function test_sync_syncAndReportErrors_server_maintenance_error() {
   enableValidationPrefs();
 
   // Test server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   const BACKOFF = 42;
   engine.enabled = true;
   engine.exception = {status: 503,
                       headers: {"retry-after": BACKOFF}};
 
   Assert.equal(Status.service, STATUS_OK);
 
-  await Service.sync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:sync:error");
 
   Assert.equal(Status.service, SYNC_FAILED_PARTIAL);
   Assert.equal(Status.sync, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_info_collections_login_syncAndReportErrors_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -165,22 +404,24 @@ add_task(async function test_info_collec
   Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
-  await Service.sync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, LOGIN_FAILED);
   Assert.equal(Status.login, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_meta_global_login_syncAndReportErrors_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -195,22 +436,24 @@ add_task(async function test_meta_global
   Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
-  await Service.sync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, LOGIN_FAILED);
   Assert.equal(Status.login, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_download_crypto_keys_login_syncAndReportErrors_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -227,22 +470,24 @@ add_task(async function test_download_cr
   Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
-  await Service.sync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, LOGIN_FAILED);
   Assert.equal(Status.login, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_upload_crypto_keys_login_syncAndReportErrors_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -257,22 +502,24 @@ add_task(async function test_upload_cryp
   Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
-  await Service.sync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, LOGIN_FAILED);
   Assert.equal(Status.login, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_wipeServer_login_syncAndReportErrors_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -287,22 +534,24 @@ add_task(async function test_wipeServer_
   Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
-  await Service.sync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, LOGIN_FAILED);
   Assert.equal(Status.login, SERVER_MAINTENANCE);
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_wipeRemote_syncAndReportErrors_server_maintenance_error() {
   enableValidationPrefs();
 
@@ -321,23 +570,225 @@ add_task(async function test_wipeRemote_
     Svc.Obs.remove("weave:service:backoff:interval", observe);
     backoffInterval = subject;
   });
 
   Assert.ok(!Status.enforceBackoff);
   Assert.equal(Status.service, STATUS_OK);
 
   Svc.Prefs.set("firstSync", "wipeRemote");
-  await Service.sync();
+  setLastSync(NON_PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:sync:error");
 
   Assert.ok(Status.enforceBackoff);
   Assert.equal(backoffInterval, 42);
   Assert.equal(Status.service, SYNC_FAILED);
   Assert.equal(Status.sync, SERVER_MAINTENANCE);
   Assert.equal(Svc.Prefs.get("firstSync"), "wipeRemote");
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_sync_syncAndReportErrors_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test prolonged server maintenance errors are
+  // reported when calling syncAndReportErrors.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  const BACKOFF = 42;
+  engine.enabled = true;
+  engine.exception = {status: 503,
+                      headers: {"retry-after": BACKOFF}};
+
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:sync:error");
+
+  Assert.equal(Status.service, SYNC_FAILED_PARTIAL);
+  Assert.equal(Status.sync, SERVER_MAINTENANCE);
+  // syncAndReportErrors means dontIgnoreErrors, which means
+  // didReportProlongedError not touched.
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_info_collections_login_syncAndReportErrors_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test info/collections server maintenance errors are reported
+  // when calling syncAndReportErrors.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  await configureIdentity({username: "broken.info"}, server);
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, LOGIN_FAILED);
+  Assert.equal(Status.login, SERVER_MAINTENANCE);
+  // syncAndReportErrors means dontIgnoreErrors, which means
+  // didReportProlongedError not touched.
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_meta_global_login_syncAndReportErrors_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test meta/global server maintenance errors are reported
+  // when calling syncAndReportErrors.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  await configureIdentity({username: "broken.meta"}, server);
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, LOGIN_FAILED);
+  Assert.equal(Status.login, SERVER_MAINTENANCE);
+  // syncAndReportErrors means dontIgnoreErrors, which means
+  // didReportProlongedError not touched.
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_download_crypto_keys_login_syncAndReportErrors_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test crypto/keys server maintenance errors are reported
+  // when calling syncAndReportErrors.
+  let server = await EHTestsCommon.sync_httpd_setup();
+  await EHTestsCommon.setUp(server);
+
+  await configureIdentity({username: "broken.keys"}, server);
+  // Force re-download of keys
+  Service.collectionKeys.clear();
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, LOGIN_FAILED);
+  Assert.equal(Status.login, SERVER_MAINTENANCE);
+  // syncAndReportErrors means dontIgnoreErrors, which means
+  // didReportProlongedError not touched.
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_upload_crypto_keys_login_syncAndReportErrors_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test crypto/keys server maintenance errors are reported
+  // when calling syncAndReportErrors.
+  let server = await EHTestsCommon.sync_httpd_setup();
+
+  // Start off with an empty account, do not upload a key.
+  await configureIdentity({username: "broken.keys"}, server);
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, LOGIN_FAILED);
+  Assert.equal(Status.login, SERVER_MAINTENANCE);
+  // syncAndReportErrors means dontIgnoreErrors, which means
+  // didReportProlongedError not touched.
+  Assert.ok(!errorHandler.didReportProlongedError);
+
+  await clean();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_wipeServer_login_syncAndReportErrors_prolonged_server_maintenance_error() {
+  enableValidationPrefs();
+
+  // Test crypto/keys server maintenance errors are reported
+  // when calling syncAndReportErrors.
+  let server = await EHTestsCommon.sync_httpd_setup();
+
+  // Start off with an empty account, do not upload a key.
+  await configureIdentity({username: "broken.wipe"}, server);
+
+  let backoffInterval;
+  Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) {
+    Svc.Obs.remove("weave:service:backoff:interval", observe);
+    backoffInterval = subject;
+  });
+
+  Assert.ok(!Status.enforceBackoff);
+  Assert.equal(Status.service, STATUS_OK);
+
+  setLastSync(PROLONGED_ERROR_DURATION);
+  await syncAndReportErrorsAndWait("weave:ui:login:error");
+
+  Assert.ok(Status.enforceBackoff);
+  Assert.equal(backoffInterval, 42);
+  Assert.equal(Status.service, LOGIN_FAILED);
+  Assert.equal(Status.login, SERVER_MAINTENANCE);
+  // syncAndReportErrors means dontIgnoreErrors, which means
+  // didReportProlongedError not touched.
+  Assert.ok(!errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_engine_generic_fail() {
   enableValidationPrefs();
 
@@ -389,53 +840,55 @@ add_task(async function test_sync_engine
 
   let syncErrors = sumHistogram("WEAVE_ENGINE_SYNC_ERRORS", { key: "catapult" });
   Assert.ok(syncErrors, 1);
 
   await clean();
   await promiseStopServer(server);
 });
 
-add_task(async function test_logs_on_sync_error() {
+add_task(async function test_logs_on_sync_error_despite_shouldReportError() {
   enableValidationPrefs();
 
   _("Ensure that an error is still logged when weave:service:sync:error " +
     "is notified, despite shouldReportError returning false.");
 
   let log = Log.repository.getLogger("Sync.ErrorHandler");
   Svc.Prefs.set("log.appender.file.logOnError", true);
   log.info("TESTING");
 
   // Ensure that we report no error.
   Status.login = MASTER_PASSWORD_LOCKED;
+  Assert.ok(!errorHandler.shouldReportError());
 
   let promiseObserved = promiseOneObserver("weave:service:reset-file-log");
   Svc.Obs.notify("weave:service:sync:error", {});
   await promiseObserved;
 
   // Test that error log was written.
   let logFiles = getLogFiles();
   equal(logFiles.length, 1);
   Assert.ok(logFiles[0].leafName.startsWith("error-sync-"), logFiles[0].leafName);
 
   await clean();
 });
 
-add_task(async function test_logs_on_login_error() {
+add_task(async function test_logs_on_login_error_despite_shouldReportError() {
   enableValidationPrefs();
 
   _("Ensure that an error is still logged when weave:service:login:error " +
     "is notified, despite shouldReportError returning false.");
 
   let log = Log.repository.getLogger("Sync.ErrorHandler");
   Svc.Prefs.set("log.appender.file.logOnError", true);
   log.info("TESTING");
 
   // Ensure that we report no error.
   Status.login = MASTER_PASSWORD_LOCKED;
+  Assert.ok(!errorHandler.shouldReportError());
 
   let promiseObserved = promiseOneObserver("weave:service:reset-file-log");
   Svc.Obs.notify("weave:service:login:error", {});
   await promiseObserved;
 
   // Test that error log was written.
   let logFiles = getLogFiles();
   equal(logFiles.length, 1);
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_errorhandler_eol.js
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.import("resource://services-sync/service.js");
+ChromeUtils.import("resource://services-sync/status.js");
+ChromeUtils.import("resource://services-sync/util.js");
+
+ChromeUtils.import("resource://testing-common/services/sync/fakeservices.js");
+
+function baseHandler(eolCode, request, response, statusCode, status, body) {
+  let alertBody = {
+    code: eolCode,
+    message: "Service is EOLed.",
+    url: "http://getfirefox.com",
+  };
+  response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false);
+  response.setHeader("X-Weave-Alert", "" + JSON.stringify(alertBody), false);
+  response.setStatusLine(request.httpVersion, statusCode, status);
+  response.bodyOutputStream.write(body, body.length);
+}
+
+function handler513(request, response) {
+  let statusCode = 513;
+  let status = "Upgrade Required";
+  let body = "{}";
+  baseHandler("hard-eol", request, response, statusCode, status, body);
+}
+
+function handler200(eolCode) {
+  return function(request, response) {
+    let statusCode = 200;
+    let status = "OK";
+    let body = "{\"meta\": 123456789010}";
+    baseHandler(eolCode, request, response, statusCode, status, body);
+  };
+}
+
+function sync_httpd_setup(infoHandler) {
+  let handlers = {
+    "/1.1/johndoe/info/collections": infoHandler,
+  };
+  return httpd_setup(handlers);
+}
+
+async function setUp(server) {
+  await configureIdentity({username: "johndoe"}, server);
+  new FakeCryptoService();
+}
+
+function do_check_soft_eol(eh, start) {
+  // We subtract 1000 because the stored value is in second precision.
+  Assert.ok(eh.earliestNextAlert >= (start + eh.MINIMUM_ALERT_INTERVAL_MSEC - 1000));
+  Assert.equal("soft-eol", eh.currentAlertMode);
+}
+function do_check_hard_eol(eh, start) {
+  // We subtract 1000 because the stored value is in second precision.
+  Assert.ok(eh.earliestNextAlert >= (start + eh.MINIMUM_ALERT_INTERVAL_MSEC - 1000));
+  Assert.equal("hard-eol", eh.currentAlertMode);
+  Assert.ok(Status.eol);
+}
+
+add_task(async function test_200_hard() {
+  let eh = Service.errorHandler;
+  let start = Date.now();
+  let server = sync_httpd_setup(handler200("hard-eol"));
+  await setUp(server);
+
+  let promiseObserved = promiseOneObserver("weave:eol");
+
+  await Service._fetchInfo();
+  Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing.
+
+  let { subject } = await promiseObserved;
+  Assert.equal("hard-eol", subject.code);
+  do_check_hard_eol(eh, start);
+  Assert.equal(Service.scheduler.eolInterval, Service.scheduler.syncInterval);
+  eh.clearServerAlerts();
+  await promiseStopServer(server);
+});
+
+add_task(async function test_513_hard() {
+  let eh = Service.errorHandler;
+  let start = Date.now();
+  let server = sync_httpd_setup(handler513);
+  await setUp(server);
+
+  let promiseObserved = promiseOneObserver("weave:eol");
+
+  try {
+    await Service._fetchInfo();
+    Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing.
+  } catch (ex) {
+    // Because fetchInfo will fail on a 513.
+  }
+  let { subject } = await promiseObserved;
+  Assert.equal("hard-eol", subject.code);
+  do_check_hard_eol(eh, start);
+  Assert.equal(Service.scheduler.eolInterval, Service.scheduler.syncInterval);
+  eh.clearServerAlerts();
+
+  await promiseStopServer(server);
+});
+
+add_task(async function test_200_soft() {
+  let eh = Service.errorHandler;
+  let start = Date.now();
+  let server = sync_httpd_setup(handler200("soft-eol"));
+  await setUp(server);
+
+  let promiseObserved = promiseOneObserver("weave:eol");
+
+  await Service._fetchInfo();
+  Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing.
+  let { subject } = await promiseObserved;
+  Assert.equal("soft-eol", subject.code);
+  do_check_soft_eol(eh, start);
+  Assert.equal(Service.scheduler.singleDeviceInterval, Service.scheduler.syncInterval);
+  eh.clearServerAlerts();
+
+  await promiseStopServer(server);
+});
--- a/services/sync/tests/unit/test_errorhandler_filelog.js
+++ b/services/sync/tests/unit/test_errorhandler_filelog.js
@@ -13,18 +13,25 @@ const logsdir            = FileUtils.get
 
 // Delay to wait before cleanup, to allow files to age.
 // This is so large because the file timestamp granularity is per-second, and
 // so otherwise we can end up with all of our files -- the ones we want to
 // keep, and the ones we want to clean up -- having the same modified time.
 const CLEANUP_DELAY      = 2000;
 const DELAY_BUFFER       = 500; // Buffer for timers on different OS platforms.
 
+const PROLONGED_ERROR_DURATION =
+  (Svc.Prefs.get("errorhandler.networkFailureReportTimeout") * 2) * 1000;
+
 var errorHandler = Service.errorHandler;
 
+function setLastSync(lastSyncValue) {
+  Svc.Prefs.set("lastSync", (new Date(Date.now() - lastSyncValue)).toString());
+}
+
 function run_test() {
   validate_all_future_pings();
   run_next_test();
 }
 
 add_test(function test_noOutput() {
   // Ensure that the log appender won't print anything.
   errorHandler._logManager._fileAppender.level = Log.Level.Fatal + 1;
@@ -130,17 +137,18 @@ add_test(function test_sync_error_logOnE
     Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog);
     // No log file was written.
     Assert.ok(!logsdir.directoryEntries.hasMoreElements());
 
     Svc.Prefs.resetBranch("");
     run_next_test();
   });
 
-  // Fake an unsuccessful sync.
+  // Fake an unsuccessful sync due to prolonged failure.
+  setLastSync(PROLONGED_ERROR_DURATION);
   Svc.Obs.notify("weave:service:sync:error");
 });
 
 add_test(function test_sync_error_logOnError_true() {
   Svc.Prefs.set("log.appender.file.logOnError", true);
 
   let log = Log.repository.getLogger("Sync.Test.FileLog");
   const MESSAGE = "this WILL show up";
@@ -170,17 +178,18 @@ add_test(function test_sync_error_logOnE
         // Stupid Windows box.
       }
 
       Svc.Prefs.resetBranch("");
       run_next_test();
     });
   });
 
-  // Fake an unsuccessful sync.
+  // Fake an unsuccessful sync due to prolonged failure.
+  setLastSync(PROLONGED_ERROR_DURATION);
   Svc.Obs.notify("weave:service:sync:error");
 });
 
 add_test(function test_login_error_logOnError_false() {
   Svc.Prefs.set("log.appender.file.logOnError", false);
 
   let log = Log.repository.getLogger("Sync.Test.FileLog");
   log.info("this won't show up");
@@ -189,17 +198,18 @@ add_test(function test_login_error_logOn
     Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog);
     // No log file was written.
     Assert.ok(!logsdir.directoryEntries.hasMoreElements());
 
     Svc.Prefs.resetBranch("");
     run_next_test();
   });
 
-  // Fake an unsuccessful login.
+  // Fake an unsuccessful login due to prolonged failure.
+  setLastSync(PROLONGED_ERROR_DURATION);
   Svc.Obs.notify("weave:service:login:error");
 });
 
 add_test(function test_login_error_logOnError_true() {
   Svc.Prefs.set("log.appender.file.logOnError", true);
 
   let log = Log.repository.getLogger("Sync.Test.FileLog");
   const MESSAGE = "this WILL show up";
@@ -229,17 +239,18 @@ add_test(function test_login_error_logOn
         // Stupid Windows box.
       }
 
       Svc.Prefs.resetBranch("");
       run_next_test();
     });
   });
 
-  // Fake an unsuccessful login.
+  // Fake an unsuccessful login due to prolonged failure.
+  setLastSync(PROLONGED_ERROR_DURATION);
   Svc.Obs.notify("weave:service:login:error");
 });
 
 add_test(function test_noNewFailed_noErrorLog() {
   Svc.Prefs.set("log.appender.file.logOnError", true);
   Svc.Prefs.set("log.appender.file.logOnSuccess", false);
 
   Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() {
@@ -337,17 +348,18 @@ add_test(function test_errorLog_dumpAddo
         // Stupid Windows box.
       }
 
       Svc.Prefs.resetBranch("");
       run_next_test();
     });
   });
 
-  // Fake an unsuccessful sync.
+  // Fake an unsuccessful sync due to prolonged failure.
+  setLastSync(PROLONGED_ERROR_DURATION);
   Svc.Obs.notify("weave:service:sync:error");
 });
 
 // Check that error log files are deleted above an age threshold.
 add_test(function test_logErrorCleanup_age() {
   _("Beginning test_logErrorCleanup_age.");
   let maxAge = CLEANUP_DELAY / 1000;
   let oldLogs = [];
--- a/services/sync/tests/unit/test_fxa_node_reassignment.js
+++ b/services/sync/tests/unit/test_fxa_node_reassignment.js
@@ -20,16 +20,23 @@ ChromeUtils.import("resource://gre/modul
 
 add_task(async function setup() {
   // Disables all built-in engines. Important for avoiding errors thrown by the
   // add-ons engine.
   await Service.engineManager.clear();
 
   // Setup the FxA identity manager and cluster manager.
   Status.__authManager = Service.identity = new BrowserIDManager();
+
+  // None of the failures in this file should result in a UI error.
+  function onUIError() {
+    do_throw("Errors should not be presented in the UI.");
+  }
+  Svc.Obs.add("weave:ui:login:error", onUIError);
+  Svc.Obs.add("weave:ui:sync:error", onUIError);
 });
 
 
 // API-compatible with SyncServer handler. Bind `handler` to something to use
 // as a ServerCollection handler.
 function handleReassign(handler, req, resp) {
   resp.setStatusLine(req.httpVersion, 401, "Node reassignment");
   resp.setHeader("Content-Type", "application/json");
--- a/services/sync/tests/unit/test_node_reassignment.js
+++ b/services/sync/tests/unit/test_node_reassignment.js
@@ -11,16 +11,23 @@ ChromeUtils.import("resource://services-
 ChromeUtils.import("resource://services-sync/status.js");
 ChromeUtils.import("resource://services-sync/util.js");
 ChromeUtils.import("resource://testing-common/services/sync/rotaryengine.js");
 ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm");
 
 
 add_task(async function setup() {
   validate_all_future_pings();
+
+  // None of the failures in this file should result in a UI error.
+  function onUIError() {
+    do_throw("Errors should not be presented in the UI.");
+  }
+  Svc.Obs.add("weave:ui:login:error", onUIError);
+  Svc.Obs.add("weave:ui:sync:error", onUIError);
 });
 
 /**
  * Emulate the following Zeus config:
  * $draining = data.get($prefix . $host . " draining");
  * if ($draining == "drain.") {
  *   log.warn($log_host_db_status . " migrating=1 (node-reassignment)" .
  *            $log_suffix);
--- a/services/sync/tests/unit/test_service_sync_updateEnabledEngines.js
+++ b/services/sync/tests/unit/test_service_sync_updateEnabledEngines.js
@@ -215,18 +215,21 @@ add_task(async function test_disabledLoc
   await setUp(server);
 
   _("Disable engine locally.");
   Service._ignorePrefObserver = true;
   engine.enabled = true;
   Service._ignorePrefObserver = false;
   engine.enabled = false;
 
+  let promiseObserved = promiseOneObserver("weave:ui:sync:error");
+
   _("Sync.");
-  await Service.sync();
+  Service.errorHandler.syncAndReportErrors();
+  await promiseObserved;
   Assert.equal(Service.status.sync, SERVER_MAINTENANCE);
 
   await Service.startOver();
   await promiseStopServer(server);
 });
 
 add_task(async function test_enabledRemotely() {
   enableValidationPrefs();
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -99,16 +99,17 @@ run-sequentially = Frequent timeouts, bu
 [test_errorhandler_2.js]
 run-sequentially = Frequent timeouts, bug 1395148
 [test_errorhandler_filelog.js]
 # Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini)
 skip-if = os == "android"
 [test_errorhandler_sync_checkServerError.js]
 # Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini)
 skip-if = os == "android"
+[test_errorhandler_eol.js]
 [test_hmac_error.js]
 [test_interval_triggers.js]
 [test_node_reassignment.js]
 run-sequentially = Frequent timeouts, bug 1395148
 [test_score_triggers.js]
 [test_status.js]
 [test_status_checkSetup.js]
 [test_syncscheduler.js]
--- a/services/sync/tps/extensions/tps/resource/tps.jsm
+++ b/services/sync/tps/extensions/tps/resource/tps.jsm
@@ -263,16 +263,17 @@ var TPS = {
   },
 
   /**
    * Given that we cannot complely disable the automatic sync operations, we
    * massively delay the next sync. Sync operations have to only happen when
    * directly called via TPS.Sync()!
    */
   delayAutoSync: function TPS_delayAutoSync() {
+    Weave.Svc.Prefs.set("scheduler.eolInterval", 7200);
     Weave.Svc.Prefs.set("scheduler.immediateInterval", 7200);
     Weave.Svc.Prefs.set("scheduler.idleInterval", 7200);
     Weave.Svc.Prefs.set("scheduler.activeInterval", 7200);
     Weave.Svc.Prefs.set("syncThreshold", 10000000);
   },
 
   StartAsyncOperation: function TPS__StartAsyncOperation() {
     this._operations_pending++;
--- a/tools/lint/eslint/modules.json
+++ b/tools/lint/eslint/modules.json
@@ -24,17 +24,17 @@
   "BootstrapMonitor.jsm": ["monitor"],
   "browser-loader.js": ["BrowserLoader"],
   "browserid_identity.js": ["BrowserIDManager", "AuthenticationError"],
   "CertUtils.jsm": ["BadCertHandler", "checkCert", "readCertPrefs", "validateCert"],
   "clients.js": ["ClientEngine", "ClientsRec"],
   "collection_repair.js": ["getRepairRequestor", "getAllRepairRequestors", "CollectionRepairRequestor", "getRepairResponder", "CollectionRepairResponder"],
   "collection_validator.js": ["CollectionValidator", "CollectionProblemData"],
   "Console.jsm": ["console", "ConsoleAPI"],
-  "constants.js": ["WEAVE_VERSION", "SYNC_API_VERSION", "STORAGE_VERSION", "PREFS_BRANCH", "DEFAULT_KEYBUNDLE_NAME", "SYNC_KEY_ENCODED_LENGTH", "SYNC_KEY_DECODED_LENGTH", "NO_SYNC_NODE_INTERVAL", "MAX_ERROR_COUNT_BEFORE_BACKOFF", "MINIMUM_BACKOFF_INTERVAL", "MAXIMUM_BACKOFF_INTERVAL", "HMAC_EVENT_INTERVAL", "MASTER_PASSWORD_LOCKED_RETRY_INTERVAL", "DEFAULT_GUID_FETCH_BATCH_SIZE", "DEFAULT_DOWNLOAD_BATCH_SIZE", "SINGLE_USER_THRESHOLD", "MULTI_DEVICE_THRESHOLD", "SCORE_INCREMENT_SMALL", "SCORE_INCREMENT_MEDIUM", "SCORE_INCREMENT_XLARGE", "SCORE_UPDATE_DELAY", "IDLE_OBSERVER_BACK_DELAY", "URI_LENGTH_MAX", "MAX_HISTORY_UPLOAD", "MAX_HISTORY_DOWNLOAD", "STATUS_OK", "SYNC_FAILED", "LOGIN_FAILED", "SYNC_FAILED_PARTIAL", "CLIENT_NOT_CONFIGURED", "STATUS_DISABLED", "MASTER_PASSWORD_LOCKED", "LOGIN_SUCCEEDED", "SYNC_SUCCEEDED", "ENGINE_SUCCEEDED", "LOGIN_FAILED_NO_USERNAME", "LOGIN_FAILED_NO_PASSPHRASE", "LOGIN_FAILED_NETWORK_ERROR", "LOGIN_FAILED_SERVER_ERROR", "LOGIN_FAILED_INVALID_PASSPHRASE", "LOGIN_FAILED_LOGIN_REJECTED", "METARECORD_DOWNLOAD_FAIL", "VERSION_OUT_OF_DATE", "CREDENTIALS_CHANGED", "ABORT_SYNC_COMMAND", "NO_SYNC_NODE_FOUND", "OVER_QUOTA", "SERVER_MAINTENANCE", "RESPONSE_OVER_QUOTA", "ENGINE_UPLOAD_FAIL", "ENGINE_DOWNLOAD_FAIL", "ENGINE_UNKNOWN_FAIL", "ENGINE_APPLY_FAIL", "ENGINE_BATCH_INTERRUPTED", "kSyncMasterPasswordLocked", "kSyncWeaveDisabled", "kSyncNetworkOffline", "kSyncBackoffNotMet", "kFirstSyncChoiceNotMade", "kSyncNotConfigured", "kFirefoxShuttingDown", "DEVICE_TYPE_DESKTOP", "DEVICE_TYPE_MOBILE", "SQLITE_MAX_VARIABLE_NUMBER"],
+  "constants.js": ["WEAVE_VERSION", "SYNC_API_VERSION", "STORAGE_VERSION", "PREFS_BRANCH", "DEFAULT_KEYBUNDLE_NAME", "SYNC_KEY_ENCODED_LENGTH", "SYNC_KEY_DECODED_LENGTH", "NO_SYNC_NODE_INTERVAL", "MAX_ERROR_COUNT_BEFORE_BACKOFF", "MINIMUM_BACKOFF_INTERVAL", "MAXIMUM_BACKOFF_INTERVAL", "HMAC_EVENT_INTERVAL", "MASTER_PASSWORD_LOCKED_RETRY_INTERVAL", "DEFAULT_GUID_FETCH_BATCH_SIZE", "DEFAULT_DOWNLOAD_BATCH_SIZE", "SINGLE_USER_THRESHOLD", "MULTI_DEVICE_THRESHOLD", "SCORE_INCREMENT_SMALL", "SCORE_INCREMENT_MEDIUM", "SCORE_INCREMENT_XLARGE", "SCORE_UPDATE_DELAY", "IDLE_OBSERVER_BACK_DELAY", "URI_LENGTH_MAX", "MAX_HISTORY_UPLOAD", "MAX_HISTORY_DOWNLOAD", "STATUS_OK", "SYNC_FAILED", "LOGIN_FAILED", "SYNC_FAILED_PARTIAL", "CLIENT_NOT_CONFIGURED", "STATUS_DISABLED", "MASTER_PASSWORD_LOCKED", "LOGIN_SUCCEEDED", "SYNC_SUCCEEDED", "ENGINE_SUCCEEDED", "LOGIN_FAILED_NO_USERNAME", "LOGIN_FAILED_NO_PASSPHRASE", "LOGIN_FAILED_NETWORK_ERROR", "LOGIN_FAILED_SERVER_ERROR", "LOGIN_FAILED_INVALID_PASSPHRASE", "LOGIN_FAILED_LOGIN_REJECTED", "METARECORD_DOWNLOAD_FAIL", "VERSION_OUT_OF_DATE", "CREDENTIALS_CHANGED", "ABORT_SYNC_COMMAND", "NO_SYNC_NODE_FOUND", "OVER_QUOTA", "PROLONGED_SYNC_FAILURE", "SERVER_MAINTENANCE", "RESPONSE_OVER_QUOTA", "ENGINE_UPLOAD_FAIL", "ENGINE_DOWNLOAD_FAIL", "ENGINE_UNKNOWN_FAIL", "ENGINE_APPLY_FAIL", "ENGINE_BATCH_INTERRUPTED", "kSyncMasterPasswordLocked", "kSyncWeaveDisabled", "kSyncNetworkOffline", "kSyncBackoffNotMet", "kFirstSyncChoiceNotMade", "kSyncNotConfigured", "kFirefoxShuttingDown", "DEVICE_TYPE_DESKTOP", "DEVICE_TYPE_MOBILE", "SQLITE_MAX_VARIABLE_NUMBER"],
   "Constants.jsm": ["Roles", "Events", "Relations", "Filters", "States", "Prefilters"],
   "ContactDB.jsm": ["ContactDB", "DB_NAME", "STORE_NAME", "SAVED_GETALL_STORE_NAME", "REVISION_STORE", "DB_VERSION"],
   "content-server.jsm": ["init"],
   "content.jsm": ["registerContentFrame"],
   "ContentCrashHandlers.jsm": ["TabCrashHandler", "PluginCrashReporter", "UnsubmittedCrashHandler"],
   "ContentObservers.js": [],
   "ContentPrefUtils.jsm": ["ContentPref", "cbHandleResult", "cbHandleError", "cbHandleCompletion", "safeCallback", "_methodsCallableFromChild"],
   "cookies.js": ["Cookies"],