tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 69b667350d2edf2795f703b81bc946946bd2e7db
parent e2521cc605c1c7c0fd5c84a3bf0853e40f3cc944
Author: scottdowne <sdowne@mozilla.com>
Date:   Wed,  5 Nov 2025 18:19:07 +0000

Bug 1997475 - Newtab weather retry change. r=home-newtab-reviewers,reemhamz,nanj

Differential Revision: https://phabricator.services.mozilla.com/D270777

Diffstat:
Mbrowser/extensions/newtab/lib/WeatherFeed.sys.mjs | 57++++++++++++++++++++++++++++++++++++++++++---------------
Mbrowser/extensions/newtab/test/xpcshell/test_WeatherFeed.js | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 157 insertions(+), 15 deletions(-)

diff --git a/browser/extensions/newtab/lib/WeatherFeed.sys.mjs b/browser/extensions/newtab/lib/WeatherFeed.sys.mjs @@ -46,6 +46,7 @@ import { const CACHE_KEY = "weather_feed"; const WEATHER_UPDATE_TIME = 10 * 60 * 1000; // 10 minutes const MERINO_PROVIDER = ["accuweather"]; +const RETRY_DELAY_MS = 60 * 1000; // 1 minute in ms. const MERINO_CLIENT_KEY = "HNT_WEATHER_FEED"; const PREF_WEATHER_QUERY = "weather.query"; @@ -63,6 +64,7 @@ export class WeatherFeed { this.lastUpdated = null; this.locationData = {}; this.fetchTimer = null; + this.retryTimer = null; this.fetchIntervalMs = 30 * 60 * 1000; // 30 minutes this.timeoutMS = 5000; this.lastFetchTimeMs = 0; @@ -100,10 +102,12 @@ export class WeatherFeed { return; } - lazy.clearTimeout(this.fetchTimer); + this.clearTimeout(this.fetchTimer); + this.clearTimeout(this.retryTimer); this.merino = null; this.suggestions = null; this.fetchTimer = 0; + this.retryTimer = 0; } async fetch() { @@ -177,10 +181,12 @@ export class WeatherFeed { } restartFetchTimer(ms = this.fetchIntervalMs) { - lazy.clearTimeout(this.fetchTimer); - this.fetchTimer = lazy.setTimeout(() => { + this.clearTimeout(this.fetchTimer); + this.clearTimeout(this.retryTimer); + this.fetchTimer = this.setTimeout(() => { this.fetch(); }, ms); + this.retryTimer = null; // tidy } async fetchLocationAutocomplete() { @@ -312,7 +318,7 @@ export class WeatherFeed { * This thin wrapper around the fetch call makes it easier for us to write * automated tests that simulate responses. */ - async _fetchHelper(retries = 3, queryOverride = null) { + async _fetchHelper(maxRetries = 1, queryOverride = null) { this.restartFetchTimer(); const weatherQuery = this.store.getState().Prefs.values[PREF_WEATHER_QUERY]; @@ -341,24 +347,39 @@ export class WeatherFeed { } } - let suggestions; - let retry = 0; - - while (retry++ < retries && !suggestions?.length) { + const attempt = async (retry = 0) => { try { - suggestions = await this.merino.fetch({ + // Because this can happen after a timeout, + // we want to ensure if it was called later after a teardown, + // we don't throw. If we throw, we end up in another retry. + if (!this.merino) { + return []; + } + return await this.merino.fetch({ query, providers: MERINO_PROVIDER, timeoutMs: 7000, otherParams, }); - } catch (error) { - // We don't need to do anything with this right now. + } catch (e) { + // If we get an error, we try again in 1 minute, + // and give up if we try more than maxRetries number of times. + if (retry >= maxRetries) { + return []; + } + await new Promise(res => { + // store the timeout so it can be cancelled elsewhere + this.retryTimer = this.setTimeout(() => { + this.retryTimer = null; // cleanup once it fires + res(); + }, RETRY_DELAY_MS); + }); + return attempt(retry + 1); } - } + }; - // results from the API or empty array if null - return suggestions ?? []; + // results from the API or empty array + return await attempt(); } async _fetchNormalizedLocation() { @@ -399,7 +420,7 @@ export class WeatherFeed { } /** - * Creating a thin wrapper around MerinoClient, PersistentCache, and Date. + * Creating a thin wrapper around external tools. * This makes it easier for us to write automated tests that simulate responses. */ WeatherFeed.prototype.MerinoClient = (...args) => { @@ -414,3 +435,9 @@ WeatherFeed.prototype.PersistentCache = (...args) => { WeatherFeed.prototype.Date = () => { return Date; }; +WeatherFeed.prototype.setTimeout = (...args) => { + return lazy.setTimeout(...args); +}; +WeatherFeed.prototype.clearTimeout = (...args) => { + return lazy.clearTimeout(...args); +}; diff --git a/browser/extensions/newtab/test/xpcshell/test_WeatherFeed.js b/browser/extensions/newtab/test/xpcshell/test_WeatherFeed.js @@ -497,3 +497,118 @@ add_task(async function test_detect_location_with_geolocation() { sandbox.restore(); } }); + +function setupFetchHelperHarness( + sandbox, + outcomes /* e.g. ['reject','resolve'] */ +) { + // Prevent the “next fetch” scheduling inside fetchHelper(). + sandbox.stub(WeatherFeed.prototype, "restartFetchTimer").returns(undefined); + + // Stub the timeout and capture the retry callback. + let timeoutCallback = null; + const setTimeoutStub = sandbox + .stub(WeatherFeed.prototype, "setTimeout") + .callsFake(cb => { + timeoutCallback = cb; + return 1; + }); + + const feed = new WeatherFeed(); + + // Minimal store so fetchHelper can read prefs. + feed.store = { + dispatch: sinon.spy(), + getState() { + return { Prefs: { values: {} } }; + }, + }; + + const fetchStub = sinon.stub(); + + // Fail or pass each fetch. + outcomes.forEach((outcome, index) => { + if (outcome === "reject") { + fetchStub.onCall(index).rejects(new Error(`fail${index}`)); + } else if (outcome === "resolve") { + fetchStub.onCall(index).resolves([{ city_name: "RetryCity" }]); + } + }); + feed.merino = { fetch: fetchStub }; + + return { + feed, + setTimeoutStub, + triggerRetry: () => timeoutCallback && timeoutCallback(), + }; +} + +add_task(async function test_fetchHelper_retry_resolve() { + const sandbox = sinon.createSandbox(); + + const { feed, setTimeoutStub, triggerRetry } = setupFetchHelperHarness( + sandbox, + ["reject", "resolve"] + ); + + // After retry success, fetchHelper should resolve to RetryCity. + const promise = feed._fetchHelper(1, "q"); + + // Let the first attempt run and schedule the retry. + await Promise.resolve(); + + Assert.equal(feed.merino.fetch.callCount, 1); + Assert.equal(setTimeoutStub.callCount, 1); + Assert.ok( + setTimeoutStub.calledWith(sinon.match.func, 60 * 1000), + "retry waits 60s (virtually)" + ); + + // Fire the retry. + triggerRetry(); + const results = await promise; + + Assert.equal(feed.merino.fetch.callCount, 2, "retried exactly once"); + Assert.deepEqual( + results, + [{ city_name: "RetryCity" }], + "returned retry result" + ); + + sandbox.restore(); +}); + +add_task(async function test_fetchHelper_retry_reject() { + const sandbox = sinon.createSandbox(); + + const { feed, setTimeoutStub, triggerRetry } = setupFetchHelperHarness( + sandbox, + ["reject", "reject"] + ); + + // After retry also fails, fetchHelper should resolve to []. + const promise = feed._fetchHelper(1, "q"); + + // Let the first attempt run and schedule the retry. + await Promise.resolve(); + + Assert.equal(feed.merino.fetch.callCount, 1); + Assert.equal(setTimeoutStub.callCount, 1); + Assert.ok( + setTimeoutStub.calledWith(sinon.match.func, 60 * 1000), + "retry waits 60s (virtually)" + ); + + // Fire the retry. + triggerRetry(); + const results = await promise; + + Assert.equal( + feed.merino.fetch.callCount, + 2, + "retried exactly once then gave up" + ); + Assert.deepEqual(results, [], "returns empty array after exhausting retries"); + + sandbox.restore(); +});