Compare commits

..

2 Commits

Author SHA1 Message Date
Florian Rival
d41998ec1b Update posthog to latest version 2025-10-14 14:28:25 +02:00
Florian Rival
f917d1597e Fix leaderboard entry rate limiting (#7906)
- This enforce slimits of 12 successful saves per minute globally and 6 per minute per
leaderboard. This is enough for the very large majority of games and avoid issues when sending too many scores because of a mistake or wrong game design.
2025-10-14 12:28:09 +02:00
3 changed files with 140 additions and 130 deletions

View File

@@ -8,83 +8,6 @@ namespace gdjs {
let _hasPlayerJustClosedLeaderboardView = false;
let _preferSendConnectedPlayerScore = true;
// Rolling-window rate limiting state (successful entries only):
// - Global: at most 12 successful entries across all leaderboards in the past minute
// - Per-leaderboard: at most 6 successful entries on the same leaderboard in the past minute
const ROLLING_WINDOW_MS = 60 * 1000;
const GLOBAL_SUCCESS_LIMIT_PER_MINUTE = 12;
const PER_LEADERBOARD_SUCCESS_LIMIT_PER_MINUTE = 6;
// Store timestamps of successful entries to implement the rolling window.
let _successfulEntriesTimestampsGlobal: number[] = [];
let _successfulEntriesTimestampsByLeaderboard: {
[leaderboardId: string]: number[];
} = {};
// Reservations to avoid concurrency overshoot (successes + reservations never exceed limits).
let _reservedSendsGlobal: number = 0;
let _reservedSendsByLeaderboard: { [leaderboardId: string]: number } = {};
const _pruneOldSuccessfulEntries = () => {
const threshold = Date.now() - ROLLING_WINDOW_MS;
// Global pruning
_successfulEntriesTimestampsGlobal = _successfulEntriesTimestampsGlobal.filter(
(ts) => ts >= threshold
);
// Per-leaderboard pruning
Object.keys(_successfulEntriesTimestampsByLeaderboard).forEach(
(leaderboardId) => {
const timestamps = _successfulEntriesTimestampsByLeaderboard[
leaderboardId
];
const pruned = timestamps.filter((ts) => ts >= threshold);
_successfulEntriesTimestampsByLeaderboard[leaderboardId] = pruned;
}
);
};
const _attemptReserveQuota = (leaderboardId: string): boolean => {
_pruneOldSuccessfulEntries();
const globalCount = _successfulEntriesTimestampsGlobal.length;
const reservedGlobal = _reservedSendsGlobal;
const perLeaderboardTimestamps =
_successfulEntriesTimestampsByLeaderboard[leaderboardId] || [];
const perLeaderboardCount = perLeaderboardTimestamps.length;
const reservedForLeaderboard =
_reservedSendsByLeaderboard[leaderboardId] || 0;
const wouldExceedGlobal =
globalCount + reservedGlobal >= GLOBAL_SUCCESS_LIMIT_PER_MINUTE;
const wouldExceedPerLeaderboard =
perLeaderboardCount + reservedForLeaderboard >=
PER_LEADERBOARD_SUCCESS_LIMIT_PER_MINUTE;
if (wouldExceedGlobal || wouldExceedPerLeaderboard) return false;
_reservedSendsGlobal += 1;
_reservedSendsByLeaderboard[leaderboardId] =
( _reservedSendsByLeaderboard[leaderboardId] || 0) + 1;
return true;
};
const _releaseReservedQuota = (leaderboardId: string): void => {
if (_reservedSendsGlobal > 0) _reservedSendsGlobal -= 1;
if ((_reservedSendsByLeaderboard[leaderboardId] || 0) > 0) {
_reservedSendsByLeaderboard[leaderboardId] -= 1;
}
};
const _recordSuccessfulEntry = (leaderboardId: string): void => {
const now = Date.now();
_successfulEntriesTimestampsGlobal.push(now);
const perLeaderboard =
_successfulEntriesTimestampsByLeaderboard[leaderboardId] || [];
perLeaderboard.push(now);
_successfulEntriesTimestampsByLeaderboard[leaderboardId] = perLeaderboard;
_pruneOldSuccessfulEntries();
};
gdjs.registerRuntimeScenePostEventsCallback(() => {
// Set it back to false for the next frame.
_hasPlayerJustClosedLeaderboardView = false;
@@ -110,17 +33,51 @@ namespace gdjs {
claimSecret?: string;
};
// Rolling window rate limiting
// Implements rate limiting to prevent abuse:
// - Maximum 12 successful successful entries per minute across all leaderboards
// - Maximum 6 successful successful entries per minute per individual leaderboard
// - Works in addition to existing 500ms cooldown between entry tentatives
let _successfulEntriesGlobal: number[] = []; // Timestamps of successful entries across all leaderboards
const GLOBAL_RATE_LIMIT_COUNT = 12;
const PER_LEADERBOARD_RATE_LIMIT_COUNT = 6;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute in milliseconds
/**
* Clean old entries from the rolling window (older than 1 minute)
*/
const cleanOldEntries = (
entries: number[],
currentTime: number
): number[] => {
return entries.filter(
(timestamp) => currentTime - timestamp < RATE_LIMIT_WINDOW_MS
);
};
/**
* Check if adding a new entry would exceed global rate limits.
*/
const wouldExceedGlobalSuccessRateLimit = (): boolean => {
const currentTime = Date.now();
_successfulEntriesGlobal = cleanOldEntries(
_successfulEntriesGlobal,
currentTime
);
return _successfulEntriesGlobal.length >= GLOBAL_RATE_LIMIT_COUNT;
};
/**
* Record a successful entry for global rate limiting tracking.
*/
const recordGlobalSuccessfulEntry = (): void => {
const currentTime = Date.now();
_successfulEntriesGlobal.push(currentTime);
};
/**
* Hold the state of the save of a score for a leaderboard.
*
* Existing protections:
* - 500ms per-leaderboard throttle between save starts (error code: TOO_FAST).
* - Ignore same player+score as previous successful save (error code: SAME_AS_PREVIOUS).
* - Ignore duplicate in-flight save for same player/score.
*
* New rolling-window limits (successful entries only):
* - Global: at most 12 successful entries across all leaderboards in the past minute.
* - Per-leaderboard: at most 6 successful entries on the same leaderboard in the past minute.
*/
class ScoreSavingState {
lastScoreSavingStartedAt: number | null = null;
@@ -129,6 +86,9 @@ namespace gdjs {
/** The promise that will be resolved when the score saving is done (successfully or not). */
lastSavingPromise: Promise<void> | null = null;
/** Timestamps of successful entries for this leaderboard (for rate limiting) */
private _successfulEntries: number[] = [];
// Score that is being saved:
private _currentlySavingScore: number | null = null;
private _currentlySavingPlayerName: string | null = null;
@@ -193,20 +153,41 @@ namespace gdjs {
);
}
private _isTooSoonToSaveAnotherScore(): boolean {
private _wouldExceedPerLeaderboardTentativeRateLimit(): boolean {
// Prevent entries within 500ms of each other (per leaderboard)
// as this would indicate surely a score saved every frame.
//
// Note that is on lastScoreSavingStartedAt, not lastScoreSavingSucceededAt,
// which means we limit tentatives here (and not successes).
return (
!!this.lastScoreSavingStartedAt &&
Date.now() - this.lastScoreSavingStartedAt < 500
);
}
private _wouldExceedPerLeaderboardSuccessRateLimit(): boolean {
const currentTime = Date.now();
this._successfulEntries = cleanOldEntries(
this._successfulEntries,
currentTime
);
return (
this._successfulEntries.length >= PER_LEADERBOARD_RATE_LIMIT_COUNT
);
}
private _recordPerLeaderboardAndGlobalSuccessfulEntry(): void {
const currentTime = Date.now();
this._successfulEntries.push(currentTime);
recordGlobalSuccessfulEntry();
}
startSaving({
leaderboardId,
playerName,
playerId,
score,
}: {
leaderboardId: string;
playerName?: string;
playerId?: string;
score: number;
@@ -229,7 +210,7 @@ namespace gdjs {
throw new Error('Ignoring this saving request.');
}
if (this._isTooSoonToSaveAnotherScore()) {
if (this._wouldExceedPerLeaderboardTentativeRateLimit()) {
logger.warn(
'Last entry was sent too little time ago. Ignoring this one.'
);
@@ -242,15 +223,21 @@ namespace gdjs {
throw new Error('Ignoring this saving request.');
}
// New rolling-window limits (in addition to the existing 500ms per-leaderboard throttle above):
// - Global limit: at most 12 successful entries across all leaderboards in the past minute.
// - Per-leaderboard limit: at most 6 successful entries on the same leaderboard in the past minute.
// These are enforced by reserving a slot before sending and releasing it after completion.
if (!_attemptReserveQuota(leaderboardId)) {
// Rolling window rate limiting check for successful entries.
if (wouldExceedGlobalSuccessRateLimit()) {
logger.warn(
'Too many leaderboard entries were sent in the last minute. Ignoring this one.'
'Rate limit exceeded. Too many entries have been successfully sent recently across all leaderboards. Ignoring this one.'
);
this._setError('TOO_MANY_ENTRIES_IN_A_MINUTE');
this._setError('GLOBAL_RATE_LIMIT_EXCEEDED');
throw new Error('Ignoring this saving request.');
}
if (this._wouldExceedPerLeaderboardSuccessRateLimit()) {
logger.warn(
'Rate limit exceeded. Too many entries have been successfully sent recently for this leaderboard. Ignoring this one.'
);
this._setError('LEADERBOARD_RATE_LIMIT_EXCEEDED');
throw new Error('Ignoring this saving request.');
}
@@ -269,16 +256,14 @@ namespace gdjs {
return {
closeSaving: (leaderboardEntry) => {
// Record successful entry for rolling window rate limiting.
this._recordPerLeaderboardAndGlobalSuccessfulEntry();
if (savingPromise !== this.lastSavingPromise) {
logger.info(
'Score saving result received, but another save was launched in the meantime - ignoring the result of this one.'
);
// Still record the successful entry for rate limiting purposes,
// then release the reserved quota taken at start.
_recordSuccessfulEntry(leaderboardId);
_releaseReservedQuota(leaderboardId);
// Still finish the promise that can be waited upon:
resolveSavingPromise();
return;
@@ -291,10 +276,6 @@ namespace gdjs {
this.lastSavedLeaderboardEntry = leaderboardEntry;
this.hasScoreBeenSaved = true;
// Record the success and release the reservation.
_recordSuccessfulEntry(leaderboardId);
_releaseReservedQuota(leaderboardId);
resolveSavingPromise();
},
closeSavingWithError: (errorCode) => {
@@ -303,22 +284,12 @@ namespace gdjs {
'Score saving result received, but another save was launched in the meantime - ignoring the result of this one.'
);
// Release the reserved quota taken at start.
_releaseReservedQuota(leaderboardId);
// Still finish the promise that can be waited upon:
resolveSavingPromise();
return;
}
this._setError(errorCode);
// If the entry was actually saved but response couldn't be parsed,
// still count it as a success for rate limiting.
if (errorCode === 'SAVED_ENTRY_CANT_BE_READ') {
_recordSuccessfulEntry(leaderboardId);
}
// On error, release the reservation (success recorded only if above case).
_releaseReservedQuota(leaderboardId);
resolveSavingPromise();
},
};
@@ -515,7 +486,10 @@ namespace gdjs {
try {
const { closeSaving, closeSavingWithError } =
scoreSavingState.startSaving({ leaderboardId, playerName, score });
scoreSavingState.startSaving({
playerName,
score,
});
try {
const leaderboardEntry = await saveScore({
@@ -559,7 +533,10 @@ namespace gdjs {
try {
const { closeSaving, closeSavingWithError } =
scoreSavingState.startSaving({ leaderboardId, playerId, score });
scoreSavingState.startSaving({
playerId,
score,
});
try {
const leaderboardEntryId = await saveScore({

View File

@@ -33,7 +33,7 @@
"path-browserify": "^1.0.1",
"pixi-spine": "4.0.4",
"pixi.js-legacy": "7.4.2",
"posthog-js": "^1.57.2",
"posthog-js": "1.275.2",
"prop-types": "^15.5.10",
"qr-creator": "^1.0.0",
"react": "16.14.0",
@@ -5770,6 +5770,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@posthog/core": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.3.0.tgz",
"integrity": "sha512-hxLL8kZNHH098geedcxCz8y6xojkNYbmJEW+1vFXsmPcExyCXIUUJ/34X6xa9GcprKxd0Wsx3vfJQLQX4iVPhw=="
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"license": "BSD-3-Clause"
@@ -25646,16 +25651,43 @@
}
},
"node_modules/posthog-js": {
"version": "1.57.2",
"license": "MIT",
"version": "1.275.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.275.2.tgz",
"integrity": "sha512-g1fnV/GAcEdwwk4EVbJ1HMZhlhgKYxG1Z5KPGvr+q5re0ltyVq8jFA2PsF333jvOlI8R01LLdpYSIgU8sBiZfg==",
"dependencies": {
"fflate": "^0.4.1",
"rrweb-snapshot": "^1.1.14"
"@posthog/core": "1.3.0",
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
}
},
"node_modules/posthog-js/node_modules/core-js": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/preact": {
"version": "10.13.1",
"license": "MIT",
"version": "10.27.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -30091,10 +30123,6 @@
"node": ">= 10.13.0"
}
},
"node_modules/rrweb-snapshot": {
"version": "1.1.14",
"license": "MIT"
},
"node_modules/rtl-css-js": {
"version": "1.14.0",
"license": "MIT",
@@ -33106,6 +33134,11 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
"dev": true,

View File

@@ -69,7 +69,7 @@
"path-browserify": "^1.0.1",
"pixi-spine": "4.0.4",
"pixi.js-legacy": "7.4.2",
"posthog-js": "^1.57.2",
"posthog-js": "1.275.2",
"prop-types": "^15.5.10",
"qr-creator": "^1.0.0",
"react": "16.14.0",