openvibers/openvibe.tours Commits
Place photos: under Media's hourly limit, and a 429 pauses copies (#111)
Changes
@@@ -2,8 +2,11 @@// A checked Commons image is copied once into Media as a public object. The file's Commons page,// author and licence stay on the place row; Media serves only the smaller image bytes.--// Media takes at most 30 uploads a minute from one caller (media.object.upload); one every UPLOAD_GAP_MS stays under it.--const UPLOAD_GAP_MS = 2_500;++// Media takes at most 30 uploads a minute and 600 an hour from one caller (media.object.upload, 429 rate_limited).++// One every UPLOAD_GAP_MS stays under both (about 550 an hour); a 429 pauses every copy for PAUSE_MS.++const UPLOAD_GAP_MS = 6_500;++const PAUSE_MS = 10 * 60 * 1000;++let pausedUntil = 0;// One pace for the whole process: the places job and on-demand discovery each make a cache, and together they must// stay under Media's limit too.let nextUploadAt = 0;@@@ -29,6 +32,8 @@ function createPhotoCache({ config, s, fetchImpl = globalThis.fetch, log = conso}async function cache(image) {if (!image?.sourceUrl || !image?.url) return image;++ // Media said "too many" a moment ago: skip (the place is deferred and tried at the next run).++ if (Date.now() < pausedUntil) return null;if (config.drill) return image.provider === 'openverse' ? null : image;if (!config.oauth.clientSecret) return null;const old = await s.db.maybe('SELECT media_url FROM tours_photo_assets WHERE source_url=$1', [image.sourceUrl]);@@@ -64,6 +69,11 @@ function createPhotoCache({ config, s, fetchImpl = globalThis.fetch, log = consoON CONFLICT(source_url) DO NOTHING`, [image.sourceUrl, obj.id, url]);return { ...image, url };} catch (err) {++ if (err && (err.status === 429 || /\b429\b|rate_limited/.test(String(err.message)))) {++ if (Date.now() >= pausedUntil) log.warn(`[Tours photos] Media is rate limiting uploads: copies pause for ${PAUSE_MS / 60000} minutes (${err.message})`);++ pausedUntil = Date.now() + PAUSE_MS;++ return null;++ }log.warn(`[Tours photos] Media copy unavailable: ${err.message}`);return null;}@@@ -44,5 +44,15 @@ const objectsClient = { upload: async (bytes) => { uploaded.push(bytes.length);objectsClient: { upload: async () => { times.push(Date.now()); return { id: 'o', public_url: 'https://openvibe.media/api/v2/tours/objects/o' }; } } });await Promise.all([paced.cache({ ...image, sourceUrl: image.sourceUrl + '1' }), paced.cache({ ...image, sourceUrl: image.sourceUrl + '2' })]);assert.ok(times.length === 2 && times[1] - times[0] >= 140, 'one upload per gap');++ // 5. Media says 429: copies pause (the next one downloads nothing) and the pause is logged once.++ const warned429 = [];++ let fetched = 0;++ const limited = createPhotoCache({ config, s: { db }, uploadGapMs: 0, log: { warn: (m) => warned429.push(m) }, fetchImpl: async () => { fetched += 1; return ok(); },++ objectsClient: { upload: async () => { throw Object.assign(new Error("POST /objects -> 429 rate_limited"), { status: 429 }); } } });++ assert.strictEqual(await limited.cache({ ...image, sourceUrl: image.sourceUrl + "429a" }), null);++ const before = fetched;++ assert.strictEqual(await limited.cache({ ...image, sourceUrl: image.sourceUrl + "429b" }), null);++ assert.strictEqual(fetched, before, "paused: nothing is downloaded");++ assert.strictEqual(warned429.filter((m) => /rate limiting uploads/.test(m)).length, 1, "the pause is logged once");console.log('photo cache: 960px first, the imageinfo URL as fallback, refusals logged');})().catch((e) => { console.error(e); process.exitCode = 1; });
@@ -2,8 +2,11 @@
@@ -2,8 +2,11 @@
// A checked Commons image is copied once into Media as a public object. The file's Commons page,
// A checked Commons image is copied once into Media as a public object. The file's Commons page,
// author and licence stay on the place row; Media serves only the smaller image bytes.
// author and licence stay on the place row; Media serves only the smaller image bytes.
-// Media takes at most 30 uploads a minute from one caller (media.object.upload); one every UPLOAD_GAP_MS stays under it.
-const UPLOAD_GAP_MS = 2_500;
+// Media takes at most 30 uploads a minute and 600 an hour from one caller (media.object.upload, 429 rate_limited).
+// One every UPLOAD_GAP_MS stays under both (about 550 an hour); a 429 pauses every copy for PAUSE_MS.
+const UPLOAD_GAP_MS = 6_500;
+const PAUSE_MS = 10 * 60 * 1000;
+let pausedUntil = 0;
// One pace for the whole process: the places job and on-demand discovery each make a cache, and together they must
// One pace for the whole process: the places job and on-demand discovery each make a cache, and together they must
// stay under Media's limit too.
// stay under Media's limit too.
let nextUploadAt = 0;
let nextUploadAt = 0;
@@ -29,6 +32,8 @@ function createPhotoCache({ config, s, fetchImpl = globalThis.fetch, log = conso
@@ -29,6 +32,8 @@ function createPhotoCache({ config, s, fetchImpl = globalThis.fetch, log = conso
}
}
async function cache(image) {
async function cache(image) {
if (!image?.sourceUrl || !image?.url) return image;
if (!image?.sourceUrl || !image?.url) return image;
+ // Media said "too many" a moment ago: skip (the place is deferred and tried at the next run).
+ if (Date.now() < pausedUntil) return null;
if (config.drill) return image.provider === 'openverse' ? null : image;
if (config.drill) return image.provider === 'openverse' ? null : image;
if (!config.oauth.clientSecret) return null;
if (!config.oauth.clientSecret) return null;
const old = await s.db.maybe('SELECT media_url FROM tours_photo_assets WHERE source_url=$1', [image.sourceUrl]);
const old = await s.db.maybe('SELECT media_url FROM tours_photo_assets WHERE source_url=$1', [image.sourceUrl]);
@@ -64,6 +69,11 @@ function createPhotoCache({ config, s, fetchImpl = globalThis.fetch, log = conso
@@ -64,6 +69,11 @@ function createPhotoCache({ config, s, fetchImpl = globalThis.fetch, log = conso
ON CONFLICT(source_url) DO NOTHING`, [image.sourceUrl, obj.id, url]);
ON CONFLICT(source_url) DO NOTHING`, [image.sourceUrl, obj.id, url]);
return { ...image, url };
return { ...image, url };
} catch (err) {
} catch (err) {
+ if (err && (err.status === 429 || /\b429\b|rate_limited/.test(String(err.message)))) {
+ if (Date.now() >= pausedUntil) log.warn(`[Tours photos] Media is rate limiting uploads: copies pause for ${PAUSE_MS / 60000} minutes (${err.message})`);
+ pausedUntil = Date.now() + PAUSE_MS;
+ return null;
+ }
log.warn(`[Tours photos] Media copy unavailable: ${err.message}`);
log.warn(`[Tours photos] Media copy unavailable: ${err.message}`);
return null;
return null;
}
}
@@ -44,5 +44,15 @@ const objectsClient = { upload: async (bytes) => { uploaded.push(bytes.length);
@@ -44,5 +44,15 @@ const objectsClient = { upload: async (bytes) => { uploaded.push(bytes.length);
objectsClient: { upload: async () => { times.push(Date.now()); return { id: 'o', public_url: 'https://openvibe.media/api/v2/tours/objects/o' }; } } });
objectsClient: { upload: async () => { times.push(Date.now()); return { id: 'o', public_url: 'https://openvibe.media/api/v2/tours/objects/o' }; } } });
await Promise.all([paced.cache({ ...image, sourceUrl: image.sourceUrl + '1' }), paced.cache({ ...image, sourceUrl: image.sourceUrl + '2' })]);
await Promise.all([paced.cache({ ...image, sourceUrl: image.sourceUrl + '1' }), paced.cache({ ...image, sourceUrl: image.sourceUrl + '2' })]);
assert.ok(times.length === 2 && times[1] - times[0] >= 140, 'one upload per gap');
assert.ok(times.length === 2 && times[1] - times[0] >= 140, 'one upload per gap');
+ // 5. Media says 429: copies pause (the next one downloads nothing) and the pause is logged once.
+ const warned429 = [];
+ let fetched = 0;
+ const limited = createPhotoCache({ config, s: { db }, uploadGapMs: 0, log: { warn: (m) => warned429.push(m) }, fetchImpl: async () => { fetched += 1; return ok(); },
+ objectsClient: { upload: async () => { throw Object.assign(new Error("POST /objects -> 429 rate_limited"), { status: 429 }); } } });
+ assert.strictEqual(await limited.cache({ ...image, sourceUrl: image.sourceUrl + "429a" }), null);
+ const before = fetched;
+ assert.strictEqual(await limited.cache({ ...image, sourceUrl: image.sourceUrl + "429b" }), null);
+ assert.strictEqual(fetched, before, "paused: nothing is downloaded");
+ assert.strictEqual(warned429.filter((m) => /rate limiting uploads/.test(m)).length, 1, "the pause is logged once");
console.log('photo cache: 960px first, the imageinfo URL as fallback, refusals logged');
console.log('photo cache: 960px first, the imageinfo URL as fallback, refusals logged');
})().catch((e) => { console.error(e); process.exitCode = 1; });
})().catch((e) => { console.error(e); process.exitCode = 1; });