Repository: firebase/firebase-tools
Pull request #1400 (2019-06-14 14:40:22)

 Implement LRO polling utility
### Summary
Added the `LroPoller` class which is a utility to poll LRO returned from rest api calls with exponential backoff strategy

### Change details
- Added interface `LroPollerOptions`, which applies configurations for polling (ex: resource name, exponential backoff initial delay, master timeout...)
- Added interface `LroResult`, which should have `error` **or** `response`, but **not both**
- `LroPoller` class with `poll(options)` to poll a long operation. This is going to be used to poll project and app creation LROs.
  - The utility makes use of the updated version of `Throttler` which supports exponential retries with `masterTimeout` (#1378).
  - `poll(options)` will reject only when timed out. If polling fails due to unretriable errors (404 responses, LRO failures...), the method will resolve with an `LroResult` with `error` set
  - Responses with 500 or 503 are treated as retriable errors.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/24000928/badge)](https://coveralls.io/builds/24000928)

Coverage increased (+0.2%) to 61.919% when pulling **2494992f463aa538b96fcf7ef59ff64984aa0d08 on tc-poller** into **8ed3887933808cad3bbc69dad3db2be1b20f994c on tc-prj-management**.

TrCaM: @nbegley, I increased the default master timeout to 30 seconds in the `LroPoller`
bkendall: Be sure to "squash and merge"!

Patch:
diff --git a/src/operation-poller.ts b/src/operation-poller.ts
new file mode 100644
index 000000000..7a0b7b2b3
--- /dev/null
+++ b/src/operation-poller.ts
@@ -0,0 +1,82 @@
+import { Queue } from "./throttler/queue";
+import * as api from "./api";
+import * as FirebaseError from "./error";
+
+export interface OperationPollerOptions {
+  pollerName?: string;
+  apiOrigin: string;
+  apiVersion: string;
+  operationResourceName: string;
+  backoff?: number;
+  masterTimeout?: number;
+}
+
+const DEFAULT_INITIAL_BACKOFF_DELAY_MILLIS = 250;
+const DEFAULT_MASTER_TIMEOUT_MILLIS = 30000;
+
+interface OperationResult<T> {
+  response?: T;
+  error?: {
+    message: string;
+    code: number;
+  };
+}
+
+export class OperationPoller<T> {
+  /**
+   * Returns a promise that resolves when the operation is completed with a successful response.
+   * Rejects the promise if the masterTimeout runs out before the operation is "done", or when it is
+   * "done" with an error response, or when the api request rejects with an unrecoverable error.
+   */
+  async poll(options: OperationPollerOptions): Promise<T> {
+    const queue: Queue<() => Promise<OperationResult<T>>, OperationResult<T>> = new Queue({
+      name: options.pollerName || "LRO Poller",
+      concurrency: 1,
+      retries: Number.MAX_SAFE_INTEGER,
+      backoff: options.backoff || DEFAULT_INITIAL_BACKOFF_DELAY_MILLIS,
+    });
+
+    const masterTimeout = options.masterTimeout || DEFAULT_MASTER_TIMEOUT_MILLIS;
+
+    const { response, error } = await queue.run(this.getPollingTask(options), masterTimeout);
+    queue.close();
+    if (error) {
+      throw error instanceof FirebaseError
+        ? error
+        : new FirebaseError(error.message, { status: error.code });
+    }
+    return response!;
+  }
+
+  private getPollingTask(options: OperationPollerOptions): () => Promise<OperationResult<T>> {
+    const { apiOrigin, apiVersion, operationResourceName } = options;
+    const requestOptions = { auth: true, origin: apiOrigin };
+    return async () => {
+      let apiResponse;
+
+      try {
+        apiResponse = await api.request(
+          "GET",
+          `/${apiVersion}/${operationResourceName}`,
+          requestOptions
+        );
+      } catch (err) {
+        // Responses with 500 or 503 status code are treated as retriable errors
+        if (err.status === 500 || err.status === 503) {
+          throw err;
+        }
+        return { error: err };
+      }
+
+      if (!apiResponse.body || !apiResponse.body.done) {
+        throw new Error("Polling incomplete, should trigger retry with backoff");
+      }
+
+      return apiResponse.body as OperationResult<T>;
+    };
+  }
+}
+
+export function pollOperation<T>(options: OperationPollerOptions): Promise<T> {
+  return new OperationPoller<T>().poll(options);
+}


Test patch:
diff --git a/src/test/operation-poller.spec.ts b/src/test/operation-poller.spec.ts
new file mode 100644
index 000000000..f7c6531a0
--- /dev/null
+++ b/src/test/operation-poller.spec.ts
@@ -0,0 +1,140 @@
+import { expect } from "chai";
+import * as sinon from "sinon";
+
+import * as api from "../api";
+import * as FirebaseError from "../error";
+import { OperationPollerOptions, pollOperation } from "../operation-poller";
+import TimeoutError from "../throttler/errors/timeout-error";
+import { mockAuth } from "./helpers";
+
+const TEST_ORIGIN = "https://firebasedummy.googleapis.com.com";
+const VERSION = "v1";
+const LRO_RESOURCE_NAME = "operations/cp.3322442424242444";
+const FULL_RESOURCE_NAME = `/${VERSION}/${LRO_RESOURCE_NAME}`;
+const API_OPTIONS = { auth: true, origin: TEST_ORIGIN };
+
+describe("OperationPoller", () => {
+  describe("poll", () => {
+    let sandbox: sinon.SinonSandbox;
+    let stubApiRequest: sinon.SinonStub;
+    let pollerOptions: OperationPollerOptions;
+
+    beforeEach(() => {
+      sandbox = sinon.createSandbox();
+      mockAuth(sandbox);
+      stubApiRequest = sandbox.stub(api, "request");
+      pollerOptions = {
+        apiOrigin: TEST_ORIGIN,
+        apiVersion: VERSION,
+        operationResourceName: LRO_RESOURCE_NAME,
+        backoff: 10,
+      };
+    });
+
+    afterEach(() => {
+      sandbox.restore();
+    });
+
+    it("should return result with response field if polling succeeds", async () => {
+      const successfulResponse = {
+        done: true,
+        response: "completed",
+      };
+
+      stubApiRequest
+        .withArgs("GET", FULL_RESOURCE_NAME, API_OPTIONS)
+        .resolves({ body: successfulResponse });
+
+      expect(await pollOperation<string>(pollerOptions)).to.deep.equal("completed");
+      expect(stubApiRequest.callCount).to.equal(1);
+    });
+
+    it("should return result with error field if polling operation returns failure response", async () => {
+      const failedResponse = {
+        done: true,
+        error: {
+          message: "failed",
+          code: 7,
+        },
+      };
+      stubApiRequest
+        .withArgs("GET", FULL_RESOURCE_NAME, API_OPTIONS)
+        .resolves({ body: failedResponse });
+
+      let err;
+      try {
+        await pollOperation<string>(pollerOptions);
+      } catch (e) {
+        err = e;
+      }
+      expect(err.message).to.equal("failed");
+      expect(err.status).to.equal(7);
+      expect(stubApiRequest.callCount).to.equal(1);
+    });
+
+    it("should return result with error field if api call rejects with unrecoverable error", async () => {
+      const unrecoverableError = new FirebaseError("poll failed", { status: 404 });
+
+      stubApiRequest.withArgs("GET", FULL_RESOURCE_NAME, API_OPTIONS).rejects(unrecoverableError);
+
+      let err;
+      try {
+        await pollOperation<string>(pollerOptions);
+      } catch (e) {
+        err = e;
+      }
+      expect(err).to.equal(unrecoverableError);
+      expect(stubApiRequest.callCount).to.equal(1);
+    });
+
+    it("should retry polling if http request responds with 500 or 503 status code", async () => {
+      const retriableError1 = new FirebaseError("poll failed", { status: 500 });
+      const retriableError2 = new FirebaseError("poll failed", { status: 503 });
+      const successfulResponse = {
+        done: true,
+        response: "completed",
+      };
+
+      stubApiRequest
+        .withArgs("GET", FULL_RESOURCE_NAME, API_OPTIONS)
+        .onFirstCall()
+        .rejects(retriableError1)
+        .onSecondCall()
+        .rejects(retriableError2)
+        .onThirdCall()
+        .resolves({ body: successfulResponse });
+
+      expect(await pollOperation<string>(pollerOptions)).to.deep.equal("completed");
+      expect(stubApiRequest.callCount).to.equal(3);
+    });
+
+    it("should retry polling until the LRO is done", async () => {
+      const successfulResponse = {
+        done: true,
+        response: "completed",
+      };
+      stubApiRequest
+        .withArgs("GET", FULL_RESOURCE_NAME, API_OPTIONS)
+        .resolves({ done: false })
+        .onThirdCall()
+        .resolves({ body: successfulResponse });
+
+      expect(await pollOperation<string>(pollerOptions)).to.deep.equal("completed");
+      expect(stubApiRequest.callCount).to.equal(3);
+    });
+
+    it("should reject with TimeoutError when timed out after failed retries", async () => {
+      pollerOptions.masterTimeout = 200;
+      stubApiRequest.withArgs("GET", FULL_RESOURCE_NAME, API_OPTIONS).resolves({ done: false });
+
+      let error;
+      try {
+        await pollOperation<string>(pollerOptions);
+      } catch (err) {
+        error = err;
+      }
+      expect(error).to.be.instanceOf(TimeoutError);
+      expect(stubApiRequest.callCount).to.be.at.least(3);
+    });
+  });
+});