From 8a38942e3da362928c93750f635bc406e613c924 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Mon, 7 Sep 2026 12:57:27 -0700 Subject: [PATCH] Report social posting failures and retain successful post checkpoints --- src/app/cron.js | 8 +- src/app/local.js | 12 ++- src/app/social/posts/SocialPostBase.js | 108 ++++++++++--------------- src/app/social/social.js | 8 +- test/social/SocialPostBase.test.js | 29 ++++++- workers/updater/src/posters.mjs | 6 +- 6 files changed, 91 insertions(+), 80 deletions(-) diff --git a/src/app/cron.js b/src/app/cron.js index ec74b8a..22e0c86 100644 --- a/src/app/cron.js +++ b/src/app/cron.js @@ -6,6 +6,10 @@ console.info('Starting periodic tasks...'); // Run every hour at 10sec after the hour new CronJob('10 0 * * * *', async () => { - await updateAllLocally(); - await postLocally(); + try { + await updateAllLocally(); + await postLocally(); + } catch (error) { + console.error(error); + } }, null, true); diff --git a/src/app/local.js b/src/app/local.js index 4402c43..7cc8aa8 100644 --- a/src/app/local.js +++ b/src/app/local.js @@ -13,13 +13,17 @@ export function filesystemStorage() { } export async function updateAllLocally() { - await updateAll(filesystemStorage()); - + let updaters = await updateAll(filesystemStorage()); + if (updaters.some(updater => !updater.ok)) + throw new Error('One or more updaters failed; social posting skipped.'); return 'Done'; } -export function postLocally() { - return sendStatuses(filesystemStorage()); +export async function postLocally() { + let result = await sendStatuses(filesystemStorage()); + if (!result.ok) + throw new Error('One or more social posts failed.'); + return result; } export function testScreenshotsLocally() { diff --git a/src/app/social/posts/SocialPostBase.js b/src/app/social/posts/SocialPostBase.js index 0a1035c..a6bc3da 100644 --- a/src/app/social/posts/SocialPostBase.js +++ b/src/app/social/posts/SocialPostBase.js @@ -15,29 +15,27 @@ export default class SocialPostBase { } async maybePost() { - // Make sure we have data to post - if (!await this.getData()) { - this.info('No data to post'); - return false; + try { + // Read once: Salmon Run's getData also remembers the current shift. + let data = await this.getData(); + if (!data) + return false; + + let due = []; + for (let client of this.clients) + if (await client.canSend() && await this.shouldPostForCurrentTime(client)) + due.push(client); + + // Without credentials, retain the public-image-only shadow mode. + let enabled = await this.canPost(); + if (!due.length && (enabled || !this.getPublicImageFilename())) + return false; + + return await this.post(data, due); + } catch (error) { + this.error(`Could not prepare post: ${error.message}`); + return { ok: false, error: error.message, clients: [] }; } - - let due = []; - for (let client of this.clients) - if (await this.shouldPostForCurrentTime(client)) - due.push(client); - - if (!due.length && this.clients.length) { - this.info('Already posted for this time'); - return false; - } - - // Make sure we can post or save to a file - if (!await this.canPost() && !this.getPublicImageFilename()) { - this.error('Social API parameters not specified'); - return false; - } - - return this.post(); } async canPost() { @@ -47,55 +45,34 @@ export default class SocialPostBase { return false; } - async post() { - try { - // Get the Post's text and image - let data = await this.getData(); - let text = await this.getText(data); - let image = await this.getMedia(data, 'image/png'); + async post(data, clients) { + let results = []; + let text = await this.getText(data); + let time = await this.getDataTime(); + let image = await this.getMedia(data, 'image/png'); + await this.maybeSavePublicImage(data, image.file); + let media = { 'image/png': image }; - // Maybe save the image - await this.maybeSavePublicImage(data, image.file); - - // Some clients want a different format (Bluesky limits image size, so it gets JPEG) - let media = { 'image/png': image }; - - for (let client of this.clients) { - if (!await client.canSend()) { - continue; - } - - if (!await this.shouldPostForCurrentTime(client)) { - this.info(`Already posted to ${client.name}`); - continue; - } - - try { - let mediaType = client.mediaType ?? 'image/png'; - media[mediaType] ??= await this.getMedia(data, mediaType); - let status = { - status: text, - media: [media[mediaType]], - }; - - await client.send(status); - await this.updateLastPostTime(client); - this.info(`Posted to ${client.name}`); - } catch (e) { - this.error(`Couldn't post to ${client.name}`); - console.error(e); - } + for (let client of clients) { + try { + let mediaType = client.mediaType ?? 'image/png'; + media[mediaType] ??= await this.getMedia(data, mediaType); + await client.send({ status: text, media: [media[mediaType]] }); + await this.updateLastPostTime(client, time); + results.push({ client: client.key, ok: true }); + this.info(`Posted to ${client.name}`); + } catch (error) { + results.push({ client: client.key, ok: false, error: error.message }); + this.error(`Could not post to ${client.name}: ${error.message}`); } } - catch (e) { - this.error('Couldn\'t post Post'); - console.error(e); - } + return { ok: results.every(result => result.ok), clients: results }; } async maybeSavePublicImage(data, image) { let filename = this.getPublicImageFilename(); if (filename) { + // Keep the established public URLs; removing X does not require breaking image links. await this.publicStorage.writeBytes(`twitter-images/${filename}`, image); this.info(`Saved public image as ${filename}`); } @@ -117,7 +94,7 @@ export default class SocialPostBase { } catch (e) { this.error('Couldn\'t save screenshot'); - console.error(e); + throw e; } } @@ -156,9 +133,8 @@ export default class SocialPostBase { return (await this.getLastPostTimes(client))[key] || 0; } - async updateLastPostTime(client) { + async updateLastPostTime(client, time) { let key = this.getKey(); - let time = await this.getDataTime(); let lastPostTimes = await this.getLastPostTimes(client); lastPostTimes[key] = time; diff --git a/src/app/social/social.js b/src/app/social/social.js index 89607a2..aca2481 100644 --- a/src/app/social/social.js +++ b/src/app/social/social.js @@ -11,8 +11,12 @@ export function createClients() { * @param {object[]} [clients] */ export async function sendStatuses(storage, clients = createClients()) { - for (let post of createPosts(storage, clients)) - await post.maybePost(); + let posts = []; + for (let post of createPosts(storage, clients)) { + let result = await post.maybePost(); + posts.push({ key: post.getKey(), ...(result || { ok: true, skipped: true }) }); + } + return { ok: posts.every(post => post.ok), posts }; } export async function testScreenshots(storage, clients = createClients()) { diff --git a/test/social/SocialPostBase.test.js b/test/social/SocialPostBase.test.js index b27e950..db79bc2 100644 --- a/test/social/SocialPostBase.test.js +++ b/test/social/SocialPostBase.test.js @@ -24,7 +24,7 @@ beforeEach(() => { s = storage(); bluesky = fakeClient('bluesky'); other = fakeC test('posts to every client, saves the public image, and records the time per client', async () => { const post = new HourlyPost(s, [bluesky, other]); - assert.equal(await post.maybePost(), undefined); // post ran + assert.equal((await post.maybePost()).ok, true); assert.deepEqual(bluesky.sent, [{ status: 'Post 1', media: [{ file: IMAGE, type: 'image/png' }] }]); assert.deepEqual(other.sent, bluesky.sent); @@ -56,7 +56,9 @@ test('a client that already posted is skipped while another still posts', async test('a failing client does not record a post time and does not block the other client', async () => { const broken = fakeClient('bluesky', { fail: true }); - await new HourlyPost(s, [broken, other]).maybePost(); + const result = await new HourlyPost(s, [broken, other]).maybePost(); + assert.equal(result.ok, false); + assert.deepEqual(result.clients.map(c => c.ok), [false, true]); assert.equal(other.sent.length, 1); assert.equal(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), null); assert.deepEqual(await json(s.privateBucket, 'other-lastPostTimes.json'), { hourly: 3600 }); @@ -70,7 +72,7 @@ test('renders once per media type a client needs, with size from the screenshot' const post = new ScreenshotPost(s, [jpegClient, other]); await post.maybePost(); - assert.equal(post.images, 2); // one PNG (public copy + Other), one JPEG (Bluesky) + assert.equal(post.images, 2); // one PNG public copy, one JPEG for Bluesky assert.deepEqual(jpegClient.sent[0].media, [{ file: new Uint8Array([2]), type: 'image/jpeg', width: 2432, height: 1368 }]); assert.deepEqual(other.sent[0].media, [{ file: new Uint8Array([1]), type: 'image/png', width: 2432, height: 1368 }]); assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), new Uint8Array([1])); @@ -100,3 +102,24 @@ test('test screenshots go to public storage', async () => { await new HourlyPost(s, []).saveTestScreenshot(); assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('test-screenshots/hourly.png')).arrayBuffer()), IMAGE); }); + +test('screenshot failure is reported and does not advance post state', async () => { + const post = new HourlyPost(s, [bluesky]); + post.getImage = async () => { throw new Error('render failed'); }; + const result = await post.maybePost(); + assert.equal(result.ok, false); + assert.match(result.error, /render failed/); + assert.equal(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), null); +}); + +test('retry only sends the previously failed client, using a fresh storage view', async () => { + const broken = fakeClient('other', { fail: true }); + const first = await new HourlyPost(s, [bluesky, broken]).maybePost(); + assert.equal(first.ok, false); + const recovered = fakeClient('other'); + const fresh = { ...s, privateStorage: new s.privateStorage.constructor(s.privateBucket) }; + const second = await new HourlyPost(fresh, [bluesky, recovered]).maybePost(); + assert.equal(second.ok, true); + assert.equal(bluesky.sent.length, 1); + assert.equal(recovered.sent.length, 1); +}); diff --git a/workers/updater/src/posters.mjs b/workers/updater/src/posters.mjs index ad1f63f..f8ce361 100644 --- a/workers/updater/src/posters.mjs +++ b/workers/updater/src/posters.mjs @@ -23,9 +23,9 @@ export async function runPosters(env) { if (await client.canSend()) enabled.push(client.key); - await sendStatuses(bucketStorage(env), clients); + let result = await sendStatuses(bucketStorage(env), clients); - let summary = { ok: true, ms: Date.now() - started, clients: enabled }; - log.info('Posters finished', summary); + let summary = { ...result, ms: Date.now() - started, clients: enabled }; + log[summary.ok ? 'info' : 'error']('Posters finished', summary); return summary; }