Skip to content

Commit c0b139b

Browse files
author
Beatriz Rizental
authored
Merge pull request #497 from brizental/1712922-ping-body-limit
Bug 1712922 - Implement ping body size limit
2 parents 583ac16 + 85d7b06 commit c0b139b

File tree

4 files changed

+129
-57
lines changed

4 files changed

+129
-57
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* [#431](https://github.com/mozilla/glean.js/pull/431): BUGFIX: Record the timestamp for events before dispatching to the internal task queue.
1111
* [#462](https://github.com/mozilla/glean.js/pull/462): Implement Storage module for Qt/QML platform.
1212
* [#466](https://github.com/mozilla/glean.js/pull/466): Expose `ErrorType` enum, for using with the `testGetNumRecordedErrors` API.
13+
* [#497](https://github.com/mozilla/glean.js/pull/497): Implement limit of 1MB for ping request payload. Limit is calculated after gzip compression.
1314

1415
# v0.15.0 (2021-06-03)
1516

glean/src/core/glean.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,6 @@ class Glean {
249249
// This is fine, we are inside a dispatched task that is guaranteed to run before any
250250
// other task. No external API call will be executed before we leave this task.
251251
Context.initialized = true;
252-
Glean.pingUploader.setInitialized(true);
253252

254253
// The upload enabled flag may have changed since the last run, for
255254
// example by the changing of a config file.

glean/src/core/upload/index.ts

Lines changed: 74 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,31 @@ import { gzipSync, strToU8 } from "fflate";
77
import type Platform from "../../platform/index.js";
88
import type { Configuration } from "../config.js";
99
import { GLEAN_VERSION } from "../constants.js";
10+
import { Context } from "../context.js";
1011
import log, { LoggingLevel } from "../log.js";
1112
import type { Observer as PingsDatabaseObserver, PingInternalRepresentation } from "../pings/database.js";
1213
import type PingsDatabase from "../pings/database.js";
1314
import type PlatformInfo from "../platform_info.js";
14-
import type { UploadResult} from "./uploader.js";
15+
import type { UploadResult } from "./uploader.js";
1516
import type Uploader from "./uploader.js";
1617
import { UploadResultStatus } from "./uploader.js";
1718

1819
const LOG_TAG = "core.Upload";
1920

21+
/**
22+
* Policies for ping storage, uploading and requests.
23+
*/
24+
export class Policy {
25+
constructor (
26+
// The maximum recoverable failures allowed per uploading window.
27+
//
28+
// Limiting this is necessary to avoid infinite loops on requesting upload tasks.
29+
readonly maxRecoverableFailures: number = 3,
30+
// The maximum size in bytes a ping body may have to be eligible for upload.
31+
readonly maxPingBodySize: number = 1024 * 1024 // 1MB
32+
) {}
33+
}
34+
2035
interface QueuedPing extends PingInternalRepresentation {
2136
identifier: string
2237
}
@@ -33,6 +48,14 @@ const enum PingUploaderStatus {
3348
Cancelling,
3449
}
3550

51+
// Error to be thrown in case the final ping body is larger than MAX_PING_BODY_SIZE.
52+
class PingBodyOverflowError extends Error {
53+
constructor(message?: string) {
54+
super(message);
55+
this.name = "PingBodyOverflow";
56+
}
57+
}
58+
3659
/**
3760
* A ping uploader. Manages a queue of pending pings to upload.
3861
*
@@ -57,13 +80,13 @@ class PingUploader implements PingsDatabaseObserver {
5780

5881
private readonly platformInfo: PlatformInfo;
5982
private readonly serverEndpoint: string;
60-
private readonly pingsDatabase: PingsDatabase;
61-
62-
// Whether or not Glean was initialized, as reported by the
63-
// singleton object.
64-
private initialized = false;
6583

66-
constructor(config: Configuration, platform: Platform, pingsDatabase: PingsDatabase) {
84+
constructor(
85+
config: Configuration,
86+
platform: Platform,
87+
private readonly pingsDatabase: PingsDatabase,
88+
private readonly policy = new Policy
89+
) {
6790
this.queue = [];
6891
this.status = PingUploaderStatus.Idle;
6992
// Initialize the ping uploader with either the platform defaults or a custom
@@ -74,17 +97,6 @@ class PingUploader implements PingsDatabaseObserver {
7497
this.pingsDatabase = pingsDatabase;
7598
}
7699

77-
/**
78-
* Signals that initialization of Glean was completed.
79-
*
80-
* This is required in order to not depend on the Glean object.
81-
*
82-
* @param state An optional state to set the initialization status to.
83-
*/
84-
setInitialized(state?: boolean): void {
85-
this.initialized = state ?? true;
86-
}
87-
88100
/**
89101
* Enqueues a new ping at the end of the line.
90102
*
@@ -139,21 +151,33 @@ class PingUploader implements PingsDatabaseObserver {
139151
};
140152

141153
const stringifiedBody = JSON.stringify(ping.payload);
154+
// We prefer using `strToU8` instead of TextEncoder directly,
155+
// because it will polyfill TextEncoder if it's not present in the environment.
156+
// Environments that don't provide TextEncoder are IE and most importantly QML.
157+
const encodedBody = strToU8(stringifiedBody);
158+
159+
let finalBody: string | Uint8Array;
160+
let bodySizeInBytes: number;
142161
try {
143-
const compressedBody = gzipSync(strToU8(stringifiedBody));
162+
finalBody = gzipSync(encodedBody);
163+
bodySizeInBytes = finalBody.length;
144164
headers["Content-Encoding"] = "gzip";
145-
headers["Content-Length"] = compressedBody.length.toString();
146-
return {
147-
headers,
148-
payload: compressedBody
149-
};
150165
} catch {
151-
headers["Content-Length"] = stringifiedBody.length.toString();
152-
return {
153-
headers,
154-
payload: stringifiedBody
155-
};
166+
finalBody = stringifiedBody;
167+
bodySizeInBytes = encodedBody.length;
168+
}
169+
170+
if (bodySizeInBytes > this.policy.maxPingBodySize) {
171+
throw new PingBodyOverflowError(
172+
`Body for ping ${ping.identifier} exceeds ${this.policy.maxPingBodySize}bytes. Discarding.`
173+
);
156174
}
175+
176+
headers["Content-Length"] = bodySizeInBytes.toString();
177+
return {
178+
headers,
179+
payload: finalBody
180+
};
157181
}
158182

159183
/**
@@ -163,7 +187,7 @@ class PingUploader implements PingsDatabaseObserver {
163187
* @returns The status number of the response or `undefined` if unable to attempt upload.
164188
*/
165189
private async attemptPingUpload(ping: QueuedPing): Promise<UploadResult> {
166-
if (!this.initialized) {
190+
if (!Context.initialized) {
167191
log(
168192
LOG_TAG,
169193
"Attempted to upload a ping, but Glean is not initialized yet. Ignoring.",
@@ -172,17 +196,24 @@ class PingUploader implements PingsDatabaseObserver {
172196
return { result: UploadResultStatus.RecoverableFailure };
173197
}
174198

175-
const finalPing = await this.preparePingForUpload(ping);
176-
const result = await this.uploader.post(
177-
// We are sure that the applicationId is not `undefined` at this point,
178-
// this function is only called when submitting a ping
179-
// and that function return early when Glean is not initialized.
180-
`${this.serverEndpoint}${ping.path}`,
181-
finalPing.payload,
182-
finalPing.headers
183-
);
184-
185-
return result;
199+
try {
200+
const finalPing = await this.preparePingForUpload(ping);
201+
return await this.uploader.post(
202+
// We are sure that the applicationId is not `undefined` at this point,
203+
// this function is only called when submitting a ping
204+
// and that function return early when Glean is not initialized.
205+
`${this.serverEndpoint}${ping.path}`,
206+
finalPing.payload,
207+
finalPing.headers
208+
);
209+
} catch(e) {
210+
log(LOG_TAG, ["Error trying to build ping request:", e], LoggingLevel.Warn);
211+
// An unrecoverable failure will make sure the offending ping is removed from the queue and
212+
// deleted from the database, which is what we want here.
213+
return {
214+
result: UploadResultStatus.UnrecoverableFailure
215+
};
216+
}
186217
}
187218

188219
/**
@@ -232,7 +263,7 @@ class PingUploader implements PingsDatabaseObserver {
232263
if (result === UploadResultStatus.UnrecoverableFailure || (status && status >= 400 && status < 500)) {
233264
log(
234265
LOG_TAG,
235-
`Unrecoverable upload failure while attempting to send ping ${identifier}. Error was ${status ?? "no status"}.`,
266+
`Unrecoverable upload failure while attempting to send ping ${identifier}. Error was: ${status ?? "no status"}.`,
236267
LoggingLevel.Warn
237268
);
238269
await this.pingsDatabase.deletePing(identifier);
@@ -261,7 +292,7 @@ class PingUploader implements PingsDatabaseObserver {
261292
this.enqueuePing(nextPing);
262293
}
263294

264-
if (retries >= 3) {
295+
if (retries >= this.policy.maxRecoverableFailures) {
265296
log(
266297
LOG_TAG,
267298
"Reached maximum recoverable failures for the current uploading window. You are done.",

glean/tests/unit/core/upload/index.spec.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Context } from "../../../../src/core/context";
1111
import Glean from "../../../../src/core/glean";
1212
import PingType from "../../../../src/core/pings/ping_type";
1313
import { collectAndStorePing } from "../../../../src/core/pings/maker";
14-
import PingUploader from "../../../../src/core/upload";
14+
import PingUploader, { Policy } from "../../../../src/core/upload";
1515
import { UploadResultStatus } from "../../../../src/core/upload/uploader";
1616

1717
const sandbox = sinon.createSandbox();
@@ -38,12 +38,16 @@ async function fillUpPingsDatabase(numPings: number): Promise<string[]> {
3838
}
3939

4040
/**
41-
* Waits for the uploader on the Glean singleton to end its uploading job,
41+
* Waits for a PingUploader to end its uploading job,
4242
* if it's actualy doing any.
43+
*
44+
* By default will wait for the uploader on the Glean singleton.
45+
*
46+
* @param uploader The uploader to wait for.
4347
*/
44-
async function waitForGleanUploader(): Promise<void> {
45-
if (Glean["pingUploader"]["currentJob"]) {
46-
await Glean["pingUploader"]["currentJob"];
48+
async function waitForUploader(uploader = Glean["pingUploader"]): Promise<void> {
49+
if (uploader["currentJob"]) {
50+
await uploader["currentJob"];
4751
}
4852
}
4953

@@ -79,7 +83,6 @@ describe("PingUploader", function() {
7983

8084
// Create a new uploader and attach it to the existing storage.
8185
const uploader = new PingUploader(new Configuration(), Glean.platform, Context.pingsDatabase);
82-
uploader.setInitialized();
8386
Context.pingsDatabase.attachObserver(uploader);
8487

8588
// Mock the 'triggerUpload' function so that 'scanPendingPings' does not
@@ -104,7 +107,7 @@ describe("PingUploader", function() {
104107
it("if multiple pings are enqueued subsequently, we don't attempt to upload each ping more than once", async function () {
105108
const spy = sandbox.spy(Glean.platform.uploader, "post");
106109
await fillUpPingsDatabase(100);
107-
await waitForGleanUploader();
110+
await waitForUploader();
108111
assert.strictEqual(spy.callCount, 100);
109112
});
110113

@@ -113,7 +116,6 @@ describe("PingUploader", function() {
113116
await fillUpPingsDatabase(10);
114117

115118
const uploader = new PingUploader(new Configuration(), Glean.platform, Context.pingsDatabase);
116-
uploader.setInitialized();
117119
Context.pingsDatabase.attachObserver(uploader);
118120
await Context.pingsDatabase.scanPendingPings();
119121

@@ -132,7 +134,7 @@ describe("PingUploader", function() {
132134
// as the default exported mock already returns a success response always.
133135
await fillUpPingsDatabase(10);
134136

135-
await waitForGleanUploader();
137+
await waitForUploader();
136138
assert.deepStrictEqual(await Context.pingsDatabase.getAllPings(), {});
137139
assert.strictEqual(Glean["pingUploader"]["queue"].length, 0);
138140
});
@@ -145,7 +147,7 @@ describe("PingUploader", function() {
145147
}));
146148
await fillUpPingsDatabase(10);
147149

148-
await waitForGleanUploader();
150+
await waitForUploader();
149151
assert.deepStrictEqual(await Context.pingsDatabase.getAllPings(), {});
150152
assert.strictEqual(Glean["pingUploader"]["queue"].length, 0);
151153
});
@@ -158,7 +160,7 @@ describe("PingUploader", function() {
158160
}));
159161
await fillUpPingsDatabase(1);
160162

161-
await waitForGleanUploader();
163+
await waitForUploader();
162164
// Ping should still be there.
163165
const allPings = await Context.pingsDatabase.getAllPings();
164166
assert.deepStrictEqual(Object.keys(allPings).length, 1);
@@ -193,17 +195,56 @@ describe("PingUploader", function() {
193195
status: 500,
194196
result: UploadResultStatus.Success
195197
}));
198+
199+
// Create a new ping uploader with a fixed max recoverable failures limit.
200+
const uploader = new PingUploader(
201+
new Configuration(),
202+
Glean.platform,
203+
Context.pingsDatabase,
204+
new Policy(
205+
3, // maxRecoverableFailures
206+
)
207+
);
208+
209+
// Overwrite the Glean ping uploader with the test one.
210+
Context.pingsDatabase.attachObserver(uploader);
211+
196212
await fillUpPingsDatabase(1);
213+
await waitForUploader(uploader);
197214

198-
await waitForGleanUploader();
199215
assert.strictEqual(stub.callCount, 3);
200216
});
201217

218+
it("pings which exceed max ping body size are not sent", async function () {
219+
// Create a new ping uploader with a very low max ping body size,
220+
// so that virtually any ping body will throw an error.
221+
const uploader = new PingUploader(
222+
new Configuration(),
223+
Glean.platform,
224+
Context.pingsDatabase,
225+
new Policy(
226+
3, // maxRecoverableFailures
227+
1 // maxPingBodySize
228+
)
229+
);
230+
231+
// Overwrite the Glean ping uploader with the test one.
232+
Context.pingsDatabase.attachObserver(uploader);
233+
234+
const spy = sandbox.spy(Glean.platform.uploader, "post");
235+
// Add a bunch of pings to the database, in order to trigger upload attempts on the uploader.
236+
await fillUpPingsDatabase(10);
237+
await waitForUploader(uploader);
238+
239+
// Check that none of those pings were actually sent.
240+
assert.strictEqual(spy.callCount, 0);
241+
});
242+
202243
it("correctly build ping request", async function () {
203244
const postSpy = sandbox.spy(Glean.platform.uploader, "post");
204245

205246
const expectedDocumentId = (await fillUpPingsDatabase(1))[0];
206-
await waitForGleanUploader();
247+
await waitForUploader();
207248

208249
const url = postSpy.firstCall.args[0].split("/");
209250
const appId = url[url.length - 4];

0 commit comments

Comments
 (0)