Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion backend/src/__tests__/upload.worker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { HashLockError, SourceCdnError, StorageError } from '@backend/core/errors'
import {
HashLockError,
MediaWikiServerError,
SourceCdnError,
StorageError,
} from '@backend/core/errors'
import type { UploadService } from '@backend/db/dal/uploads'
import { beforeEach, describe, expect, it, mock } from 'bun:test'
import type { Redis } from 'ioredis'
Expand Down Expand Up @@ -86,6 +91,7 @@ describe('upload worker — retryable errors escape the processor without a DB u
['HashLockError', new HashLockError('lock already held')],
['StorageError', new StorageError('storage write failed')],
['SourceCdnError', new SourceCdnError('cdn returned 503')],
['MediaWikiServerError', new MediaWikiServerError('MediaWiki upload request failed: 503')],
])('%s propagates out of the processor and leaves the DB untouched', async (_name, error) => {
// Force the error to escape early (simulates it bubbling out of the inner try block).
mockGetById.mockImplementation(async () => {
Expand All @@ -112,6 +118,7 @@ describe('upload worker — failed handler resets status to queued on intermedia
it.each([
['HashLockError', new HashLockError('lock already held')],
['SourceCdnError', new SourceCdnError('cdn network error (ECONNRESET)')],
['MediaWikiServerError', new MediaWikiServerError('MediaWiki upload request failed: 503')],
])('%s: resets status to queued when BullMQ will retry', async (_name, error) => {
const failedHandler = capturedHandlers.get('failed')
expect(failedHandler).toBeDefined()
Expand Down Expand Up @@ -141,6 +148,7 @@ describe('upload worker — permanent BullMQ failure marks upload as failed in D
it.each([
['HashLockError', new HashLockError('lock already held')],
['SourceCdnError', new SourceCdnError('cdn returned 503')],
['MediaWikiServerError', new MediaWikiServerError('MediaWiki upload request failed: 503')],
])('%s: permanently failed job updates DB status to "failed"', async (_name, error) => {
const failedHandler = capturedHandlers.get('failed')
expect(failedHandler).toBeDefined()
Expand Down
40 changes: 40 additions & 0 deletions backend/src/__tests__/uploadClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { makeRedisMock } from '@backend/__tests__/helpers'
import {
DuplicateUploadError,
HashLockError,
MediaWikiServerError,
SourceCdnError,
StorageError,
} from '@backend/core/errors'
Expand Down Expand Up @@ -172,6 +173,45 @@ describe('MediaWikiClient.uploadFile error paths', () => {
await expect(callUploadFile(client, redis)).rejects.toBeInstanceOf(StorageError)
})

it('throws MediaWikiServerError when the chunk upload endpoint returns 5xx', async () => {
const client = new MediaWikiClient(['key', 'secret'])
// biome-ignore lint/suspicious/noExplicitAny: overriding private methods for testing
;(client as any).getCsrfToken = mock(async () => 'test-token+\\')
client.findDuplicates = mock(async () => [])
let call = 0
globalThis.fetch = mock(async () => {
call++
if (call === 1) return new Response(Buffer.from('data'), { status: 200 })
return new Response('', { status: 503 })
}) as unknown as typeof fetch
const { redis } = makeRedisMock()
await expect(callUploadFile(client, redis)).rejects.toBeInstanceOf(MediaWikiServerError)
})

it('throws a plain (non-retryable) Error, not MediaWikiServerError, when the final commit request returns 5xx', async () => {
const client = new MediaWikiClient(['key', 'secret'])
// biome-ignore lint/suspicious/noExplicitAny: overriding private methods for testing
;(client as any).getCsrfToken = mock(async () => 'test-token+\\')
client.findDuplicates = mock(async () => [])
let call = 0
globalThis.fetch = mock(async () => {
call++
if (call === 1) return new Response(Buffer.from('data'), { status: 200 }) // image download
if (call === 2)
return new Response(JSON.stringify({ upload: { filekey: 'stash-key' } }), { status: 200 }) // chunk stash succeeds
return new Response('', { status: 503 }) // final commit fails
}) as unknown as typeof fetch
const { redis } = makeRedisMock()
let caught: unknown
try {
await callUploadFile(client, redis)
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
expect(caught).not.toBeInstanceOf(MediaWikiServerError)
})

it('throws HashLockError when lock is already held', async () => {
const client = new MediaWikiClient(['key', 'secret'])
client.findDuplicates = mock(async () => [])
Expand Down
7 changes: 7 additions & 0 deletions backend/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,10 @@ export class SourceCdnError extends Error {
this.name = 'SourceCdnError'
}
}

export class MediaWikiServerError extends Error {
constructor(message: string) {
super(message)
this.name = 'MediaWikiServerError'
}
}
21 changes: 18 additions & 3 deletions backend/src/mediawiki/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { DuplicateLink } from '@backend/core/errors'
import {
DuplicateUploadError,
HashLockError,
MediaWikiServerError,
SourceCdnError,
StorageError,
} from '@backend/core/errors'
Expand Down Expand Up @@ -130,7 +131,17 @@ export class MediaWikiClient {
return titles
}

private async apiUploadChunk(formData: FormData): Promise<Record<string, unknown>> {
// isFinalCommit distinguishes the terminal commit request from per-chunk stash
// uploads. A chunk-phase 5xx is safe to retry from scratch (the stash isn't a
// public File page yet). A commit-phase 5xx is ambiguous — the page may have
// been created server-side despite the failed response — so retrying the whole
// uploadFile would re-run findDuplicates and could misclassify a real success
// as a duplicate. Only chunk-phase 5xx is surfaced as the retryable
// MediaWikiServerError; commit-phase 5xx stays a terminal Error.
private async apiUploadChunk(
formData: FormData,
isFinalCommit = false,
): Promise<Record<string, unknown>> {
const baseParams = { format: 'json', formatversion: '2' }
const url = `${config.wikimediaUrls.baseUrl}?${new URLSearchParams(baseParams).toString()}`
const authHeader = buildAuthHeader(
Expand All @@ -148,7 +159,11 @@ export class MediaWikiClient {
},
body: formData,
})
if (!res.ok) throw new Error(`MediaWiki upload request failed: ${res.status}`)
if (!res.ok) {
if (res.status >= 500 && !isFinalCommit)
throw new MediaWikiServerError(`MediaWiki upload request failed: ${res.status}`)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
throw new Error(`MediaWiki upload request failed: ${res.status}`)
}
return res.json() as Promise<Record<string, unknown>>
}

Expand Down Expand Up @@ -299,7 +314,7 @@ export class MediaWikiClient {
const commitResponse = await withCsrfTokenRetry(
`[mw] uploadFile commit (attempt ${attempt + 1})`,
() => this.getCsrfToken(),
(t) => this.apiUploadChunk(buildFormData(t)),
(t) => this.apiUploadChunk(buildFormData(t), true),
token,
)
token = commitResponse.token
Expand Down
4 changes: 3 additions & 1 deletion backend/src/workers/upload.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { decryptAccessToken } from '@backend/core/crypto'
import {
DuplicateUploadError,
HashLockError,
MediaWikiServerError,
SourceCdnError,
StorageError,
} from '@backend/core/errors'
Expand Down Expand Up @@ -230,7 +231,8 @@ export function createUploadWorker(redis: Redis, deps?: WorkerDeps): Worker<Uplo
if (
err instanceof HashLockError ||
err instanceof StorageError ||
err instanceof SourceCdnError
err instanceof SourceCdnError ||
err instanceof MediaWikiServerError
) {
throw err
}
Expand Down