ReviewOS

also looking at this

stacks/postline

Actionable Bluesky publish errors + refresh-failure handling (#17)

#24
Merged glennmichael123 wants to merge feat/bluesky-publish-hardening into main
3 files +89 -4

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

app/Services/Social/BlueskyService.tsmodified+17-4
Changes to app/Services/Social/BlueskyService.ts
@@ -1,6 +1,7 @@
11import type { BlueskySession, CrosspostTargetResult, PublishContent, PublishedPost, TimelineResult } from '../../Support/Social/types'
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
4import { describeBlueskyError } from '../../Support/Social/bluesky-errors'
45import { BlueskyApiError, BlueskyDriver } from './Drivers/BlueskyDriver'
56
67const database = db as any
@@ -261,7 +262,7 @@ export class BlueskyService {
261262 }
262263 }
263264 catch (error) {
264 const message = error instanceof Error ? error.message : String(error)
265 const message = describeBlueskyError(error)
265266 const failedAt = now()
266267 await database.updateTable('post_targets').set({
267268 status: 'failed',
@@ -388,9 +389,21 @@ export class BlueskyService {
388389 throw error
389390 }
390391
391 const session = await this.driver.refreshSession(identity.refresh_token)
392 const driver = await this.ensureDriver()
393 const refreshed = await this.saveSession(session, driver)
392 // Access JWT expired — refresh once and retry. If the refresh token is
393 // also dead, mark the identity expired so /accounts prompts a reconnect.
394 let refreshed: SocialIdentityRow
395 try {
396 const session = await this.driver.refreshSession(identity.refresh_token)
397 const driver = await this.ensureDriver()
398 refreshed = await this.saveSession(session, driver)
399 }
400 catch {
401 await database.updateTable('social_identities')
402 .set({ auth_status: 'expired', updated_at: now() })
403 .where('id', '=', identity.id)
404 .execute()
405 throw new Error('Bluesky session expired — reconnect your account on the Accounts page.')
406 }
394407 return await callback(refreshed)
395408 }
396409 }
app/Support/Social/bluesky-errors.tsadded+34-0
Changes to app/Support/Social/bluesky-errors.ts
@@ -0,0 +1,34 @@
1import { BlueskyApiError } from '../../Services/Social/Drivers/BlueskyDriver'
2
3/**
4 * Turn a raw publish/timeline failure into a short, actionable message.
5 * ATProto errors arrive as `BlueskyApiError` (HTTP status + JSON body);
6 * network failures arrive as generic fetch errors.
7 */
8export function describeBlueskyError(error: unknown): string {
9 if (error instanceof BlueskyApiError) {
10 if (error.status === 429)
11 return 'Bluesky rate limit reached — wait a few minutes, then try again.'
12 if (error.status === 401 || error.status === 403)
13 return 'Bluesky session expired — reconnect your account on the Accounts page.'
14 if (error.status === 413)
15 return 'That image is too large for Bluesky (1MB max).'
16 if (error.status >= 500)
17 return 'Bluesky is having trouble right now — try again shortly.'
18 // Prefer the ATProto body message when present (bad blob, invalid record…).
19 try {
20 const parsed = JSON.parse(error.body)
21 if (parsed?.message)
22 return `Bluesky rejected the post: ${parsed.message}`
23 if (parsed?.error)
24 return `Bluesky rejected the post: ${parsed.error}`
25 }
26 catch {}
27 return error.message
28 }
29
30 const message = error instanceof Error ? error.message : String(error)
31 if (/fetch failed|network|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|timed out|timeout/i.test(message))
32 return 'Couldn\'t reach Bluesky — check your connection and try again.'
33 return message
34}
tests/unit/bluesky-errors.test.tsadded+38-0
Changes to tests/unit/bluesky-errors.test.ts
@@ -0,0 +1,38 @@
1import { describe, expect, test } from 'bun:test'
2import { BlueskyApiError } from '../../app/Services/Social/Drivers/BlueskyDriver'
3import { describeBlueskyError } from '../../app/Support/Social/bluesky-errors'
4
5const apiError = (status: number, body = '') =>
6 new BlueskyApiError(`Bluesky API failed (${status})`, status, body)
7
8describe('describeBlueskyError', () => {
9 test('maps rate limit (429) to an actionable message', () => {
10 expect(describeBlueskyError(apiError(429))).toMatch(/rate limit/i)
11 })
12
13 test('maps auth errors (401/403) to a reconnect prompt', () => {
14 expect(describeBlueskyError(apiError(401))).toMatch(/reconnect/i)
15 expect(describeBlueskyError(apiError(403))).toMatch(/reconnect/i)
16 })
17
18 test('maps oversized image (413)', () => {
19 expect(describeBlueskyError(apiError(413))).toMatch(/1MB/i)
20 })
21
22 test('maps 5xx to a transient-trouble message', () => {
23 expect(describeBlueskyError(apiError(503))).toMatch(/trouble/i)
24 })
25
26 test('surfaces the ATProto body message for other statuses', () => {
27 const err = apiError(400, JSON.stringify({ error: 'InvalidRequest', message: 'Record too long' }))
28 expect(describeBlueskyError(err)).toBe('Bluesky rejected the post: Record too long')
29 })
30
31 test('maps network failures to a connectivity message', () => {
32 expect(describeBlueskyError(new TypeError('fetch failed'))).toMatch(/couldn't reach bluesky/i)
33 })
34
35 test('passes through a plain error message unchanged', () => {
36 expect(describeBlueskyError(new Error('Write something before publishing.'))).toBe('Write something before publishing.')
37 })
38})