Add link resolution failures, non-retryable
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user