also looking at this
Actionable Bluesky publish errors + refresh-failure handling (#17)
#24
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.
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { BlueskySession, CrosspostTargetResult, PublishContent, PublishedPost, TimelineResult } from '../../Support/Social/types' |
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | import { describeBlueskyError } from '../../Support/Social/bluesky-errors' | |
| 4 | 5 | import { BlueskyApiError, BlueskyDriver } from './Drivers/BlueskyDriver' |
| 5 | 6 | |
| 6 | 7 | const database = db as any |
| @@ -261,7 +262,7 @@ export class BlueskyService { | ||
| 261 | 262 | } |
| 262 | 263 | } |
| 263 | 264 | catch (error) { |
| 264 | const message = error instanceof Error ? error.message : String(error) | |
| 265 | const message = describeBlueskyError(error) | |
| 265 | 266 | const failedAt = now() |
| 266 | 267 | await database.updateTable('post_targets').set({ |
| 267 | 268 | status: 'failed', |
| @@ -388,9 +389,21 @@ export class BlueskyService { | ||
| 388 | 389 | throw error |
| 389 | 390 | } |
| 390 | 391 | |
| 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 | } | |
| 394 | 407 | return await callback(refreshed) |
| 395 | 408 | } |
| 396 | 409 | } |
| @@ -0,0 +1,34 @@ | ||
| 1 | import { 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 | */ | |
| 8 | export 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 | } | |
| @@ -0,0 +1,38 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { BlueskyApiError } from '../../app/Services/Social/Drivers/BlueskyDriver' | |
| 3 | import { describeBlueskyError } from '../../app/Support/Social/bluesky-errors' | |
| 4 | ||
| 5 | const apiError = (status: number, body = '') => | |
| 6 | new BlueskyApiError(`Bluesky API failed (${status})`, status, body) | |
| 7 | ||
| 8 | describe('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 | }) | |