Add link resolution failures, non-retryable

This commit is contained in:
Eric Bailey
2026-05-05 09:50:10 -05:00
parent 0c8faaacd6
commit b119c36229
4 changed files with 104 additions and 18 deletions
@@ -7,15 +7,28 @@ import {beforeEach, describe, expect, jest, test} from '@jest/globals'
// Avoid pulling the UI module chain (gallery → media picker → ALF) into the
// test environment. Tests inject `__resolveLink` directly, so the real
// implementation is never invoked.
jest.mock('#/lib/api/resolve', () => ({
resolveLink: jest.fn(),
}))
// implementation is never invoked. We do mirror EmbeddingDisabledError so
// `instanceof` checks in parseErrorCode still match the imported class.
jest.mock('#/lib/api/resolve', () => {
class EmbeddingDisabledError extends Error {
constructor() {
super('Embedding is disabled for this record')
}
}
return {
resolveLink: jest.fn(),
EmbeddingDisabledError,
}
})
jest.mock('#/state/session/agent', () => ({
createPublicAgent: jest.fn(() => ({})),
}))
import {type ResolvedLink, type resolveLink} from '#/lib/api/resolve'
import {
EmbeddingDisabledError,
type ResolvedLink,
type resolveLink,
} from '#/lib/api/resolve'
import {createThreadStore} from '#/components/ComposerV2/store'
const POST_URL = 'https://bsky.app/profile/test.bsky.social/post/abc'
@@ -170,15 +183,31 @@ describe('addUri pre-classifies bsky post URLs to the quote slot', () => {
const failed = store.getState().posts[root].quote
if (failed?.state !== 'failed') throw new Error('expected failed')
expect(failed.error).toContain('post deleted')
expect(failed.code).toBe('unknown')
expect(typeof failed.retry).toBe('function')
failed.retry()
failed.retry?.()
expect(store.getState().posts[root].quote?.state).toBe('pending')
d2.resolve(postLink)
await flushPromises()
expect(store.getState().posts[root].quote?.state).toBe('resolved')
})
test('embedding-disabled error is non-retryable (no retry on failed)', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, POST_URL)
d.reject(new EmbeddingDisabledError())
await flushPromises()
const failed = store.getState().posts[root].quote
if (failed?.state !== 'failed') throw new Error('expected failed')
expect(failed.code).toBe('embedding-disabled')
expect(failed.retry).toBeUndefined()
})
})
describe('addUri pre-classifies non-post URLs to the embed slot', () => {
@@ -283,8 +312,9 @@ describe('addUri pre-classifies non-post URLs to the embed slot', () => {
const failed = store.getState().posts[root].embed
if (failed?.state !== 'failed') throw new Error('expected failed')
expect(failed.error).toContain('network down')
expect(failed.code).toBe('unknown')
failed.retry()
failed.retry?.()
expect(store.getState().posts[root].embed?.state).toBe('pending')
d2.resolve(externalLink)
+18 -3
View File
@@ -367,8 +367,12 @@ export function createThreadStore(options: {
// Pre-classification said this was a post URL. If resolveLink disagrees
// (deleted post, embedding disabled, network error, etc.), surface as
// failed in the quote slot.
// failed in the quote slot. Non-retryable failure codes (e.g.
// embedding-disabled) get a failed state with no `retry()`; the user
// has to remove the embed manually.
if (outcome.kind !== 'post') {
const code: types.LinkResolutionFailureCode =
outcome.embed.state === 'failed' ? outcome.embed.code : 'unknown'
const error =
outcome.embed.state === 'failed'
? outcome.embed.error
@@ -377,7 +381,11 @@ export function createThreadStore(options: {
state: 'failed',
uri,
error,
retry: () => addUri(postId, uri),
code,
retry:
code === 'embedding-disabled'
? undefined
: () => addUri(postId, uri),
}
mutateState(s => {
const p = s.posts[postId]
@@ -414,6 +422,7 @@ export function createThreadStore(options: {
state: 'failed',
uri,
error: 'Unexpected post outcome for non-post URL',
code: 'unknown',
retry: () => addUri(postId, uri),
}
mutateState(s => {
@@ -428,7 +437,13 @@ export function createThreadStore(options: {
const embed = outcome.embed
const stored: types.PostEmbed =
embed.state === 'failed'
? {...embed, retry: () => addUri(postId, embed.uri)}
? {
...embed,
retry:
embed.code === 'embedding-disabled'
? undefined
: () => addUri(postId, embed.uri),
}
: embed
mutateState(s => {
const p = s.posts[postId]
@@ -18,12 +18,14 @@ import {
} from '@atproto/api'
import {
EmbeddingDisabledError,
type ResolvedLink,
type resolveLink as defaultResolveLink,
} from '#/lib/api/resolve'
import {resolveLink as importedResolveLink} from '#/lib/api/resolve'
import {type ComposerImage} from '#/state/gallery'
import {createPublicAgent} from '#/state/session/agent'
import {type LinkResolutionFailureCode} from './types'
/**
* What the worker (or a test) reports back about a resolved URI. `pending`
@@ -32,7 +34,12 @@ import {createPublicAgent} from '#/state/session/agent'
* worker runs.
*/
export type EmbedResolution =
| {state: 'failed'; uri: string; error: string}
| {
state: 'failed'
uri: string
error: string
code: LinkResolutionFailureCode
}
| {
state: 'external'
uri: string
@@ -87,11 +94,23 @@ export function startUriResolution(opts: StartUriResolutionOptions): void {
state: 'failed',
uri: opts.uri,
error: String((err && (err as Error).message) ?? err),
code: parseErrorCode(err),
},
}),
)
}
/**
* Classify a thrown error into a stable failure code that drives UI
* behavior. Today we only special-case EmbeddingDisabledError (which
* `resolveLink` throws when fetching a post the author has marked
* non-embeddable); everything else falls into 'unknown' and is retryable.
*/
export function parseErrorCode(err: unknown): LinkResolutionFailureCode {
if (err instanceof EmbeddingDisabledError) return 'embedding-disabled'
return 'unknown'
}
function mapResolvedLink(link: ResolvedLink): LinkResolutionOutcome {
if (link.type === 'external') {
return {
+29 -7
View File
@@ -83,11 +83,21 @@ export type PostEmbedMedia =
| (PostEmbedMediaGif & {kind: 'gif'})
/**
* What's stored on a post's `embed` field. The failed variant has a bound
* `retry()` so UI can call it directly without having to look up the post
* id. The pending variant is set synchronously by addUri while resolution
* is in flight; the resolved variants (external/feed/list/starter-pack)
* land when the worker reports back.
* Coarse classification of why a link resolution failed. Drives UI
* affordances — for example, `embedding-disabled` is a permanent rejection
* (embedding the post is forbidden by the author), so the failed variant
* does not carry a `retry()`. Anything else falls under `unknown` and is
* retryable.
*/
export type LinkResolutionFailureCode = 'embedding-disabled' | 'unknown'
/**
* What's stored on a post's `embed` field. The failed variant carries a
* bound `retry()` for retryable codes; for permanent failures (e.g.
* `embedding-disabled`) `retry` is omitted so UI can detect that case and
* surface a non-retryable message. The pending variant is set synchronously
* by addUri while resolution is in flight; the resolved variants
* (external/feed/list/starter-pack) land when the worker reports back.
*
* Note: `retry` is a function reference and won't survive JSON serialization.
* On restore (OS-resume / draft load), the store re-attaches it.
@@ -96,7 +106,13 @@ export type PostEmbedMedia =
*/
export type PostEmbed =
| {state: 'pending'; uri: string}
| {state: 'failed'; uri: string; error: string; retry: () => void}
| {
state: 'failed'
uri: string
error: string
code: LinkResolutionFailureCode
retry?: () => void
}
| {
state: 'external'
uri: string
@@ -132,7 +148,13 @@ export type PostEmbed =
*/
export type PostEmbedQuote =
| {state: 'pending'; uri: string}
| {state: 'failed'; uri: string; error: string; retry: () => void}
| {
state: 'failed'
uri: string
error: string
code: LinkResolutionFailureCode
retry?: () => void
}
| {
state: 'resolved'
uri: string