1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 ChromeUtils.defineESModuleGetters(lazy, {
8 SkippableTimer: "resource:///modules/UrlbarUtils.sys.mjs",
9 UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
10 UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
13 const SEARCH_PARAMS = {
14 CLIENT_VARIANTS: "client_variants",
15 PROVIDERS: "providers",
17 SEQUENCE_NUMBER: "seq",
21 const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
23 const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS";
24 const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE";
27 * Client class for querying the Merino server. Each instance maintains its own
28 * session state including a session ID and sequence number that is included in
29 * its requests to Merino.
31 export class MerinoClient {
34 * The names of URL search params.
36 static get SEARCH_PARAMS() {
37 return { ...SEARCH_PARAMS };
41 * @param {string} name
42 * An optional name for the client. It will be included in log messages.
43 * @param {object} options
45 * @param {string} options.cachePeriodMs
46 * Enables caching when nonzero. The client will cache the response
47 * suggestions from its most recent successful request for the specified
48 * period. The client will serve the cached suggestions for all fetches for
49 * the same URL until either the cache period elapses or a successful fetch
50 * for a different URL is made (ignoring session-related URL params like
51 * session ID and sequence number). Caching is per `MerinoClient` instance
52 * and is not shared across instances.
54 * WARNING: Cached suggestions are only ever evicted when new suggestions
55 * are cached. They are not evicted on a timer. If the client has cached
56 * some suggestions and no further fetches are made, they'll stay cached
57 * indefinitely. If your request URLs contain senstive data that should not
58 * stick around in the object graph indefinitely, you should either not use
59 * caching or you should implement an eviction mechanism.
61 * This cache strategy is intentionally simplistic and designed to be used
62 * by the urlbar with very short cache periods to make sure Firefox doesn't
63 * repeatedly call the same Merino URL on each keystroke in a urlbar
64 * session, which is wasteful and can cause a suggestion to flicker out of
65 * and into the urlbar panel as the user matches it again and again,
66 * especially when Merino latency is high. It is not designed to be a
67 * general caching mechanism. If you need more complex or long-lived
68 * caching, try working with the Merino team to add cache headers to the
69 * relevant responses so you can leverage Firefox's HTTP cache.
71 constructor(name = "anonymous", { cachePeriodMs = 0 } = {}) {
73 this.#cachePeriodMs = cachePeriodMs;
74 ChromeUtils.defineLazyGetter(this, "logger", () =>
75 lazy.UrlbarUtils.getLogger({ prefix: `MerinoClient [${name}]` })
81 * The name of the client.
89 * If `resetSession()` is not called within this timeout period after a
90 * session starts, the session will time out and the next fetch will begin a
93 get sessionTimeoutMs() {
94 return this.#sessionTimeoutMs;
96 set sessionTimeoutMs(value) {
97 this.#sessionTimeoutMs = value;
102 * The current session ID. Null when there is no active session.
105 return this.#sessionID;
110 * The current sequence number in the current session. Zero when there is no
113 get sequenceNumber() {
114 return this.#sequenceNumber;
119 * A string that indicates the status of the last fetch. The values are the
120 * same as the labels used in the `FX_URLBAR_MERINO_RESPONSE` histogram:
121 * success, timeout, network_error, http_error
123 get lastFetchStatus() {
124 return this.#lastFetchStatus;
128 * Fetches Merino suggestions.
130 * @param {object} options
132 * @param {string} options.query
134 * @param {Array} options.providers
135 * Array of provider names to request from Merino. If this is given it will
136 * override the `merinoProviders` Nimbus variable and its fallback pref
137 * `browser.urlbar.merino.providers`.
138 * @param {number} options.timeoutMs
139 * Timeout in milliseconds. This method will return once the timeout
140 * elapses, a response is received, or an error occurs, whichever happens
142 * @param {string} options.extraLatencyHistogram
143 * If specified, the fetch's latency will be recorded in this histogram in
144 * addition to the usual Merino latency histogram.
145 * @param {string} options.extraResponseHistogram
146 * If specified, the fetch's response will be recorded in this histogram in
147 * addition to the usual Merino response histogram.
148 * @param {object} options.otherParams
149 * If specified, the otherParams will be added as a query params. Currently
150 * used for accuweather's location autocomplete endpoint
152 * The Merino suggestions or null if there's an error or unexpected
158 timeoutMs = lazy.UrlbarPrefs.get("merinoTimeoutMs"),
159 extraLatencyHistogram = null,
160 extraResponseHistogram = null,
163 this.logger.debug("Fetch start", { query });
165 // Get the endpoint URL. It's empty by default when running tests so they
166 // don't hit the network.
167 let endpointString = lazy.UrlbarPrefs.get("merinoEndpointURL");
168 if (!endpointString) {
173 url = new URL(endpointString);
175 this.logger.error("Error creating endpoint URL", error);
179 // Start setting search params. Leave session-related params for last.
180 url.searchParams.set(SEARCH_PARAMS.QUERY, query);
182 let clientVariants = lazy.UrlbarPrefs.get("merinoClientVariants");
183 if (clientVariants) {
184 url.searchParams.set(SEARCH_PARAMS.CLIENT_VARIANTS, clientVariants);
188 if (providers != null) {
189 if (!Array.isArray(providers)) {
190 throw new Error("providers must be an array if given");
192 providersString = providers.join(",");
194 let value = lazy.UrlbarPrefs.get("merinoProviders");
196 // The Nimbus variable/pref is used only if it's a non-empty string.
197 providersString = value;
201 // An empty providers string is a valid value and means Merino should
202 // receive the request but not return any suggestions, so do not do a simple
203 // `if (providersString)` here.
204 if (typeof providersString == "string") {
205 url.searchParams.set(SEARCH_PARAMS.PROVIDERS, providersString);
208 // if otherParams are present add them to the url
209 for (const [param, value] of Object.entries(otherParams)) {
210 url.searchParams.set(param, value);
213 // At this point, all search params should be set except for session-related
216 let details = { query, providers, timeoutMs, url: url.toString() };
217 this.logger.debug("Fetch details", details);
219 // If caching is enabled, generate the cache key for this request URL.
221 if (this.#cachePeriodMs && !MerinoClient._test_disableCache) {
222 url.searchParams.sort();
223 cacheKey = url.toString();
225 // If we have cached suggestions and they're still valid, return them.
227 this.#cache.suggestions &&
228 Date.now() < this.#cache.dateMs + this.#cachePeriodMs &&
229 this.#cache.key == cacheKey
231 this.logger.debug("Fetch served from cache");
232 return this.#cache.suggestions;
236 // At this point, we're calling Merino.
238 // Set up the Merino session ID and related state. The session ID is a UUID
239 // without leading and trailing braces.
240 if (!this.#sessionID) {
241 let uuid = Services.uuid.generateUUID().toString();
242 this.#sessionID = uuid.substring(1, uuid.length - 1);
243 this.#sequenceNumber = 0;
244 this.#sessionTimer?.cancel();
246 // Per spec, for the user's privacy, the session should time out and a new
247 // session ID should be used if the engagement does not end soon.
248 this.#sessionTimer = new lazy.SkippableTimer({
249 name: "Merino session timeout",
250 time: this.#sessionTimeoutMs,
252 callback: () => this.resetSession(),
255 url.searchParams.set(SEARCH_PARAMS.SESSION_ID, this.#sessionID);
256 url.searchParams.set(SEARCH_PARAMS.SEQUENCE_NUMBER, this.#sequenceNumber);
257 this.#sequenceNumber++;
259 let recordResponse = category => {
260 this.logger.debug("Fetch done", { status: category });
261 Services.telemetry.getHistogramById(HISTOGRAM_RESPONSE).add(category);
262 if (extraResponseHistogram) {
264 .getHistogramById(extraResponseHistogram)
267 this.#lastFetchStatus = category;
268 recordResponse = null;
271 // Set up the timeout timer.
272 let timer = (this.#timeoutTimer = new lazy.SkippableTimer({
273 name: "Merino timeout",
277 // The fetch timed out.
278 this.logger.debug("Fetch timed out", { timeoutMs });
279 recordResponse?.("timeout");
283 // If there's an ongoing fetch, abort it so there's only one at a time. By
284 // design we do not abort fetches on timeout or when the query is canceled
285 // so we can record their latency.
287 this.#fetchController?.abort();
289 this.logger.error("Error aborting previous fetch", error);
294 let controller = (this.#fetchController = new AbortController());
295 let stopwatchInstance = (this.#latencyStopwatchInstance = {});
296 TelemetryStopwatch.start(HISTOGRAM_LATENCY, stopwatchInstance);
297 if (extraLatencyHistogram) {
298 TelemetryStopwatch.start(extraLatencyHistogram, stopwatchInstance);
304 // Canceling the timer below resolves its promise, which can resolve
305 // the outer promise created by `Promise.race`. This inner async
306 // function happens not to await anything after canceling the timer,
307 // but if it did, `timer.promise` could win the race and resolve the
308 // outer promise without a value. For that reason, we declare
309 // `response` in the outer scope and set it here instead of returning
310 // the response from this inner function and assuming it will also be
311 // returned by `Promise.race`.
312 response = await fetch(url, { signal: controller.signal });
313 TelemetryStopwatch.finish(HISTOGRAM_LATENCY, stopwatchInstance);
314 if (extraLatencyHistogram) {
315 TelemetryStopwatch.finish(extraLatencyHistogram, stopwatchInstance);
317 this.logger.debug("Got response", {
318 status: response.status,
322 recordResponse?.("http_error");
325 TelemetryStopwatch.cancel(HISTOGRAM_LATENCY, stopwatchInstance);
326 if (extraLatencyHistogram) {
327 TelemetryStopwatch.cancel(extraLatencyHistogram, stopwatchInstance);
329 if (error.name != "AbortError") {
330 this.logger.error("Fetch error", error);
331 recordResponse?.("network_error");
334 // Now that the fetch is done, cancel the timeout timer so it doesn't
335 // fire and record a timeout. If it already fired, which it would have
336 // on timeout, or was already canceled, this is a no-op.
338 if (controller == this.#fetchController) {
339 this.#fetchController = null;
341 this.#nextResponseDeferred?.resolve(response);
342 this.#nextResponseDeferred = null;
346 if (timer == this.#timeoutTimer) {
347 this.#timeoutTimer = null;
350 // Get the response body as an object.
353 body = await response?.json();
355 this.logger.error("Error getting response as JSON", error);
359 this.logger.debug("Response body", body);
362 if (!body?.suggestions?.length) {
363 recordResponse?.("no_suggestion");
367 let { suggestions, request_id } = body;
368 if (!Array.isArray(suggestions)) {
369 this.logger.error("Unexpected response", body);
370 recordResponse?.("no_suggestion");
374 recordResponse?.("success");
375 suggestions = suggestions.map(suggestion => ({
393 * Resets the Merino session ID and related state.
396 this.#sessionID = null;
397 this.#sequenceNumber = 0;
398 this.#sessionTimer?.cancel();
399 this.#sessionTimer = null;
400 this.#nextSessionResetDeferred?.resolve();
401 this.#nextSessionResetDeferred = null;
405 * Cancels the timeout timer.
407 cancelTimeoutTimer() {
408 this.#timeoutTimer?.cancel();
412 * Returns a promise that's resolved when the next response is received or a
413 * network error occurs.
416 * The promise is resolved with the `Response` object or undefined if a
417 * network error occurred.
419 waitForNextResponse() {
420 if (!this.#nextResponseDeferred) {
421 this.#nextResponseDeferred = Promise.withResolvers();
423 return this.#nextResponseDeferred.promise;
427 * Returns a promise that's resolved when the session is next reset, including
428 * on session timeout.
432 waitForNextSessionReset() {
433 if (!this.#nextSessionResetDeferred) {
434 this.#nextSessionResetDeferred = Promise.withResolvers();
436 return this.#nextSessionResetDeferred.promise;
439 static _test_disableCache = false;
441 get _test_sessionTimer() {
442 return this.#sessionTimer;
445 get _test_timeoutTimer() {
446 return this.#timeoutTimer;
449 get _test_fetchController() {
450 return this.#fetchController;
453 get _test_latencyStopwatchInstance() {
454 return this.#latencyStopwatchInstance;
457 // State related to the current session.
460 #sessionTimer = null;
461 #sessionTimeoutMs = SESSION_TIMEOUT_MS;
464 #timeoutTimer = null;
465 #fetchController = null;
466 #latencyStopwatchInstance = null;
467 #lastFetchStatus = null;
468 #nextResponseDeferred = null;
469 #nextSessionResetDeferred = null;
472 // When caching is enabled, we cache response suggestions from the most recent
473 // successful request.
475 // The cached suggestions array.
477 // The cache key: the stringified request URL without session-related params
478 // (session ID and sequence number).
480 // The date the suggestions were cached as returned by `Date.now()`.