Files
tubestation/browser/components/urlbar/MerinoClient.sys.mjs
Drew Willcoxon b5ead527af Bug 1797673 - Factor out Merino code into MerinoClient and add MerinoTestUtils. r=daisuke
This factors out the Merino client code from UrlbarProviderQuickSuggest into
MerinoClient. It also adds MerinoTestUtils.

There are a few reasons for this:

1. Weather suggestions will be prefetched from Merino, and it may make sense to
   do the prefetching outside of UrlbarProviderQuickSuggest, I'm not sure
   yet.
2. Weather suggestions will be fetched from a slightly different URL from other
   suggestions. They'll need to include `providers=weather` as a search param.
   In the future there may be other types of suggestions that also require
   different parameters or whole new endpoints. Supporting options like this is
   a little nicer with a dedicated Merino client class.
3. In general this code is substantial enough that it makes sense to encapsulate
   it in its own file and class, even if the provider is the only thing that
   will use it.

This is a large patch but a lot of it moves code around. Summary of changes:

* Factor out Merino client code into MerinoClient
* Also include some new useful helpers in MerinoClient
* Consolidate and factor out Merino test code into MerinoTestUtils
* Also include some new useful helpers in MerinoTestUtils and make other improvements
* Add a MockMerinoServer class (in MerinoTestUtils.sys.mjs) to help with setting up a mock Merino server
* Add test_merinoClient.js to test the client in isolation. This file started as a copy of test_quicksuggest_merino.js because it's so similar, and some of the tests in the latter are moved to the former.
* Add test_merinoClient_sessions.js to test Merino sessions in isolation. Similar to the previous point, this file started as a copy of test_quicksuggest_merinoSessions.js.
* Modify existing Merino tests so they use MerinoTestUtils

Differential Revision: https://phabricator.services.mozilla.com/D160449
2022-10-28 21:25:55 +00:00

333 lines
10 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
SkippableTimer: "resource:///modules/UrlbarUtils.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
});
const SEARCH_PARAMS = {
CLIENT_VARIANTS: "client_variants",
PROVIDERS: "providers",
QUERY: "q",
SEQUENCE_NUMBER: "seq",
SESSION_ID: "sid",
};
const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS";
const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE";
/**
* Client class for querying the Merino server.
*/
export class MerinoClient {
/**
* @returns {object}
* The names of URL search params.
*/
static get SEARCH_PARAMS() {
return { ...SEARCH_PARAMS };
}
constructor() {
XPCOMUtils.defineLazyGetter(this, "logger", () =>
lazy.UrlbarUtils.getLogger({ prefix: "MerinoClient" })
);
}
/**
* @returns {number}
* If `resetSession()` is not called within this timeout period after a
* session starts, the session will time out and the next fetch will begin a
* new session.
*/
get sessionTimeoutMs() {
return this.#sessionTimeoutMs;
}
set sessionTimeoutMs(value) {
this.#sessionTimeoutMs = value;
}
/**
* @returns {number}
* The current session ID. Null when there is no active session.
*/
get sessionID() {
return this.#sessionID;
}
/**
* @returns {number}
* The current sequence number in the current session. Zero when there is no
* active session.
*/
get sequenceNumber() {
return this.#sequenceNumber;
}
/**
* @returns {string}
* A string that indicates the status of the last fetch. The values are the
* same as the labels used in the `FX_URLBAR_MERINO_RESPONSE` histogram:
* success, timeout, network_error, http_error
*/
get lastFetchStatus() {
return this.#lastFetchStatus;
}
/**
* Fetches Merino suggestions.
*
* @param {object} options
* Options object
* @param {string} options.query
* The search string.
* @param {Array} options.providers
* Array of provider names to request from Merino. If this is given it will
* override the `merinoProviders` Nimbus variable and its fallback pref
* `browser.urlbar.merino.providers`.
* @returns {Array}
* The Merino suggestions or null if there's an error or unexpected
* response.
*/
async fetch({ query, providers = null }) {
// Set up the Merino session ID and related state. The session ID is a UUID
// without leading and trailing braces.
if (!this.#sessionID) {
let uuid = Services.uuid.generateUUID().toString();
this.#sessionID = uuid.substring(1, uuid.length - 1);
this.#sequenceNumber = 0;
this.#sessionTimer?.cancel();
// Per spec, for the user's privacy, the session should time out and a new
// session ID should be used if the engagement does not end soon.
this.#sessionTimer = new lazy.SkippableTimer({
name: "Merino session timeout",
time: this.#sessionTimeoutMs,
logger: this.logger,
callback: () => this.resetSession(),
});
}
// Get the endpoint URL. It's empty by default when running tests so they
// don't hit the network.
let endpointString = lazy.UrlbarPrefs.get("merinoEndpointURL");
if (!endpointString) {
return null;
}
let url;
try {
url = new URL(endpointString);
} catch (error) {
this.logger.error("Could not make Merino endpoint URL: " + error);
return null;
}
url.searchParams.set(SEARCH_PARAMS.QUERY, query);
url.searchParams.set(SEARCH_PARAMS.SESSION_ID, this.#sessionID);
url.searchParams.set(SEARCH_PARAMS.SEQUENCE_NUMBER, this.#sequenceNumber);
this.#sequenceNumber++;
let clientVariants = lazy.UrlbarPrefs.get("merinoClientVariants");
if (clientVariants) {
url.searchParams.set(SEARCH_PARAMS.CLIENT_VARIANTS, clientVariants);
}
let providersString;
if (providers != null) {
if (!Array.isArray(providers)) {
throw new Error("providers must be an array if given");
}
providersString = providers.join(",");
} else {
let value = lazy.UrlbarPrefs.get("merinoProviders");
if (value) {
// The Nimbus variable/pref is used only if it's a non-empty string.
providersString = value;
}
}
// An empty providers string is a valid value and means Merino should
// receive the request but not return any suggestions, so do not do a simple
// `if (providersString)` here.
if (typeof providersString == "string") {
url.searchParams.set(SEARCH_PARAMS.PROVIDERS, providersString);
}
let recordResponse = category => {
Services.telemetry.getHistogramById(HISTOGRAM_RESPONSE).add(category);
this.#lastFetchStatus = category;
recordResponse = null;
};
// Set up the timeout timer.
let timeout = lazy.UrlbarPrefs.get("merinoTimeoutMs");
let timer = (this.#timeoutTimer = new lazy.SkippableTimer({
name: "Merino timeout",
time: timeout,
logger: this.logger,
callback: () => {
// The fetch timed out.
this.logger.info(`Merino fetch timed out (timeout = ${timeout}ms)`);
recordResponse?.("timeout");
},
}));
// If there's an ongoing fetch, abort it so there's only one at a time. By
// design we do not abort fetches on timeout or when the query is canceled
// so we can record their latency.
try {
this.#fetchController?.abort();
} catch (error) {
this.logger.error("Could not abort Merino fetch: " + error);
}
// Do the fetch.
let response;
let controller = (this.#fetchController = new AbortController());
let stopwatchInstance = (this.#latencyStopwatchInstance = {});
TelemetryStopwatch.start(HISTOGRAM_LATENCY, stopwatchInstance);
await Promise.race([
timer.promise,
(async () => {
try {
// Canceling the timer below resolves its promise, which can resolve
// the outer promise created by `Promise.race`. This inner async
// function happens not to await anything after canceling the timer,
// but if it did, `timer.promise` could win the race and resolve the
// outer promise without a value. For that reason, we declare
// `response` in the outer scope and set it here instead of returning
// the response from this inner function and assuming it will also be
// returned by `Promise.race`.
response = await fetch(url, { signal: controller.signal });
TelemetryStopwatch.finish(HISTOGRAM_LATENCY, stopwatchInstance);
recordResponse?.(response.ok ? "success" : "http_error");
} catch (error) {
TelemetryStopwatch.cancel(HISTOGRAM_LATENCY, stopwatchInstance);
if (error.name != "AbortError") {
this.logger.error("Could not fetch Merino endpoint: " + error);
recordResponse?.("network_error");
}
} finally {
// Now that the fetch is done, cancel the timeout timer so it doesn't
// fire and record a timeout. If it already fired, which it would have
// on timeout, or was already canceled, this is a no-op.
timer.cancel();
if (controller == this.#fetchController) {
this.#fetchController = null;
}
this.#nextResponseDeferred?.resolve(response);
this.#nextResponseDeferred = null;
}
})(),
]);
if (timer == this.#timeoutTimer) {
this.#timeoutTimer = null;
}
// Get the response body as an object.
let body;
try {
body = await response?.json();
} catch (error) {
this.logger.error("Could not get Merino response as JSON: " + error);
}
if (!body?.suggestions?.length) {
return [];
}
let { suggestions, request_id } = body;
if (!Array.isArray(suggestions)) {
this.logger.error("Unexpected Merino response: " + JSON.stringify(body));
return [];
}
return suggestions.map(suggestion => ({ ...suggestion, request_id }));
}
/**
* Resets the Merino session ID and related state.
*/
resetSession() {
this.#sessionID = null;
this.#sequenceNumber = 0;
this.#sessionTimer?.cancel();
this.#sessionTimer = null;
this.#nextSessionResetDeferred?.resolve();
this.#nextSessionResetDeferred = null;
}
/**
* Cancels the timeout timer.
*/
cancelTimeoutTimer() {
this.#timeoutTimer?.cancel();
}
/**
* Returns a promise that's resolved when the next response is received or a
* network error occurs.
*
* @returns {Promise}
* The promise is resolved with the `Response` object or undefined if a
* network error occurred.
*/
waitForNextResponse() {
if (!this.#nextResponseDeferred) {
this.#nextResponseDeferred = lazy.PromiseUtils.defer();
}
return this.#nextResponseDeferred.promise;
}
/**
* Returns a promise that's resolved when the session is next reset, including
* on session timeout.
*
* @returns {Promise}
*/
waitForNextSessionReset() {
if (!this.#nextSessionResetDeferred) {
this.#nextSessionResetDeferred = lazy.PromiseUtils.defer();
}
return this.#nextSessionResetDeferred.promise;
}
get _test_sessionTimer() {
return this.#sessionTimer;
}
get _test_timeoutTimer() {
return this.#timeoutTimer;
}
get _test_fetchController() {
return this.#fetchController;
}
get _test_latencyStopwatchInstance() {
return this.#latencyStopwatchInstance;
}
// State related to the current session.
#sessionID = null;
#sequenceNumber = 0;
#sessionTimer = null;
#sessionTimeoutMs = SESSION_TIMEOUT_MS;
#timeoutTimer = null;
#fetchController = null;
#latencyStopwatchInstance = null;
#lastFetchStatus = null;
#nextResponseDeferred = null;
#nextSessionResetDeferred = null;
}