fix: say which download client is not responding

Show one notice above the release picker naming the client, with a
Try again that resends the same release, instead of a chip on every
row. Grab failures carry the client name in errorMeta, and transport
errors read "connection refused" rather than "fetch failed".
This commit is contained in:
neon
2026-10-22 00:05:10 +00:00
parent 992224f2e8
commit 0320210767
8 changed files with 251 additions and 29 deletions
@@ -191,4 +191,62 @@ describe('useReleaseGrab', () => {
expect(picker.isRefused(release({ indexerId: 9, guid: 'torrent-2' }))).toBe(true) expect(picker.isRefused(release({ indexerId: 9, guid: 'torrent-2' }))).toBe(true)
expect(picker.isRefused(release({ indexerId: 5, guid: 'usenet-1' }))).toBe(false) expect(picker.isRefused(release({ indexerId: 5, guid: 'usenet-1' }))).toBe(false)
}) })
/** "Could not reach qBittorrent: fetch failed" on a toast, then the same chip on every row, was all it said. */
describe('when the download client is down', () => {
const unavailable: ReleaseGrabOutcome = {
item: null,
reason: 'Could not reach qBittorrent: connection refused',
errorCode: 'GRAB_CLIENT_UNAVAILABLE',
errorMeta: { client: 'QBT' },
}
it('names the client once instead of marking every row', async () => {
const picker = makeGrab({ grab: vi.fn<() => Promise<ReleaseGrabOutcome>>().mockResolvedValue(unavailable) })
await picker.handleGrab(release())
expect(picker.clientProblem.value).toMatchObject({ code: 'GRAB_CLIENT_UNAVAILABLE', client: 'QBT' })
expect(picker.isRefused(release({ guid: 'g2' }))).toBe(true)
expect(picker.refusalText(release({ guid: 'g2' }))).toBeNull()
expect(toastMock.error).toHaveBeenCalledWith('bookRequests.errors.grabFailed', {
description: 'bookRequests.releases.clientProblem.unavailable',
})
})
it('sends the same release again without inspecting it again', async () => {
const grab = vi
.fn<() => Promise<ReleaseGrabOutcome>>()
.mockResolvedValueOnce(unavailable)
.mockResolvedValue({ item: request, reason: null, errorCode: null })
const picker = makeGrab({ grab })
await picker.handleGrab(release())
await picker.retryClient()
expect(grab).toHaveBeenNthCalledWith(2, 7, { indexerId: 9, releaseGuid: 'g1' })
expect(picker.inspectRelease).toHaveBeenCalledTimes(1)
expect(picker.clientProblem.value).toBeNull()
expect(picker.isRefused(release({ guid: 'g2' }))).toBe(false)
})
it('keeps the notice when the client still does not answer', async () => {
const picker = makeGrab({ grab: vi.fn<() => Promise<ReleaseGrabOutcome>>().mockResolvedValue(unavailable) })
await picker.handleGrab(release())
await picker.retryClient()
expect(picker.clientProblem.value).toMatchObject({ code: 'GRAB_CLIENT_UNAVAILABLE' })
expect(picker.isRefused(release({ guid: 'g2' }))).toBe(true)
})
it('is cleared by a fresh search', async () => {
const picker = makeGrab({ grab: vi.fn<() => Promise<ReleaseGrabOutcome>>().mockResolvedValue(unavailable) })
await picker.handleGrab(release())
picker.forgetRefusals()
expect(picker.clientProblem.value).toBeNull()
})
})
}) })
@@ -7,6 +7,7 @@ import {
Check, Check,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
CircleAlert,
CircleCheck, CircleCheck,
CircleMinus, CircleMinus,
Download, Download,
@@ -179,17 +180,29 @@ const {
resetSearchEditorState, resetSearchEditorState,
} = useReleaseSearchEditor({ request, criteria, attempts, requestId, fetchReleases, beforeSearch: startFreshSearch }) } = useReleaseSearchEditor({ request, criteria, attempts, requestId, fetchReleases, beforeSearch: startFreshSearch })
const { manualOpen, grabbing, holdForReview, openManual, closeManual, forgetRefusals, handleGrab, handleManualGrab, isRefused, refusalText } = const {
useReleaseGrab({ manualOpen,
request, grabbing,
requestId, holdForReview,
grab: actions.grab, clientProblem,
setRequest, openManual,
inspectRelease, closeManual,
setFilesExpanded, forgetRefusals,
deliveryFor, handleGrab,
afterGrab, handleManualGrab,
}) retryClient,
isRefused,
refusalText,
} = useReleaseGrab({
request,
requestId,
grab: actions.grab,
setRequest,
inspectRelease,
setFilesExpanded,
deliveryFor,
afterGrab,
})
/** /**
* Every Send is off while one release is on its way, not only the row being inspected. The * Every Send is off while one release is on its way, not only the row being inspected. The
@@ -197,6 +210,20 @@ const { manualOpen, grabbing, holdForReview, openManual, closeManual, forgetRefu
* request, so `actions` has nothing to be busy about yet. * request, so `actions` has nothing to be busy about yet.
*/ */
const busy = computed(() => grabbing.value || actions.isPending(request.value?.id)) const busy = computed(() => grabbing.value || actions.isPending(request.value?.id))
const clientProblemTitle = computed(() => {
const problem = clientProblem.value
if (!problem) return ''
const kind = problem.code === 'GRAB_CLIENT_UNAVAILABLE' ? 'unavailable' : 'refused'
return problem.client
? t(`bookRequests.releases.clientProblem.${kind}`, { client: problem.client })
: t(`bookRequests.releases.clientProblem.${kind}Unnamed`)
})
const clientProblemHint = computed(() => {
const problem = clientProblem.value
if (!problem) return ''
if (problem.code === 'GRAB_CLIENT_UNAVAILABLE') return t('bookRequests.releases.clientProblem.unavailableHint')
return problem.reason ?? t('bookRequests.releases.clientProblem.refusedHint')
})
/** A retry is the same picker; only the framing changes, so the approver knows why it reopened. */ /** A retry is the same picker; only the framing changes, so the approver knows why it reopened. */
const isRetry = computed(() => request.value?.status === 'failed') const isRetry = computed(() => request.value?.status === 'failed')
const failureText = computed(() => (request.value ? requestFailureText(request.value, (key, named) => t(key, named)) : null)) const failureText = computed(() => (request.value ? requestFailureText(request.value, (key, named) => t(key, named)) : null))
@@ -733,6 +760,10 @@ function startFreshSearch(): void {
forgetRefusals() forgetRefusals()
} }
function handleRetryClient() {
void retryClient()
}
function handleRefresh() { function handleRefresh() {
startFreshSearch() startFreshSearch()
if (!Number.isInteger(requestId.value)) return if (!Number.isInteger(requestId.value)) return
@@ -1138,6 +1169,26 @@ function showEverySource(): void {
{{ t('bookRequests.releases.cached') }} {{ t('bookRequests.releases.cached') }}
</p> </p>
<!--
One notice for the client rather than a chip on every row it disables: the client is not a
property of any release, and the way out is the same whichever row was sent.
-->
<div
v-if="clientProblem && !finished"
role="alert"
class="mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm"
>
<CircleAlert :size="16" class="mt-0.5 shrink-0 text-destructive" aria-hidden="true" />
<div class="min-w-0 flex-1 basis-56">
<p class="font-medium text-foreground">{{ clientProblemTitle }}</p>
<p class="mt-0.5 text-xs break-words text-muted-foreground">{{ clientProblemHint }}</p>
</div>
<Button variant="outline" size="sm" :disabled="busy" @click="handleRetryClient">
<RefreshCw :size="14" aria-hidden="true" />
{{ t('bookRequests.releases.clientProblem.retry') }}
</Button>
</div>
<!-- Where a pair ends once neither half needs a release: said here, not left to a passing toast. --> <!-- Where a pair ends once neither half needs a release: said here, not left to a passing toast. -->
<div v-if="finished" role="status" class="mt-10 flex flex-col items-center gap-3 text-center"> <div v-if="finished" role="status" class="mt-10 flex flex-col items-center gap-3 text-center">
<span class="grid size-11 place-items-center rounded-full bg-success/15 text-success"> <span class="grid size-11 place-items-center rounded-full bg-success/15 text-success">
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { findGrabRefusal, releaseInspectionBlocksGrab } from '@bookorbit/types' import { findGrabRefusal, releaseInspectionBlocksGrab } from '@bookorbit/types'
import type { import type {
BookRequestFailureMeta,
BookRequestItem, BookRequestItem,
DownloadDelivery, DownloadDelivery,
GrabBookRequestPayload, GrabBookRequestPayload,
@@ -13,13 +14,31 @@ import type {
ReleaseFileInspection, ReleaseFileInspection,
} from '@bookorbit/types' } from '@bookorbit/types'
/** Only the refusals that rule another release out get a note; the rest are about one row. */ /**
* Only the refusals that rule another release out get a note; the rest are about one row. A client
* refusal is not about any row, so it is said once above the list instead of on every one.
*/
const REFUSAL_KEYS: Partial<Record<GrabFailureCode, string>> = { const REFUSAL_KEYS: Partial<Record<GrabFailureCode, string>> = {
GRAB_SOURCE_REFUSED: 'sourceRefused', GRAB_SOURCE_REFUSED: 'sourceRefused',
GRAB_SOURCE_UNAVAILABLE: 'sourceUnavailable', GRAB_SOURCE_UNAVAILABLE: 'sourceUnavailable',
GRAB_VIP_REQUIRED: 'vipRequired', GRAB_VIP_REQUIRED: 'vipRequired',
GRAB_CLIENT_REFUSED: 'clientRefused', }
GRAB_CLIENT_UNAVAILABLE: 'clientUnavailable',
type ClientFailureCode = Extract<GrabFailureCode, 'GRAB_CLIENT_REFUSED' | 'GRAB_CLIENT_UNAVAILABLE'>
function isClientFailure(code: GrabFailureCode | null): code is ClientFailureCode {
return code === 'GRAB_CLIENT_REFUSED' || code === 'GRAB_CLIENT_UNAVAILABLE'
}
/** The download client that failed the last send, and that send, so it can be tried once more. */
export interface ClientProblem {
code: ClientFailureCode
/** The client's configured name, when the server said which one. */
client: string | null
/** The client's own words. Only shown for a refusal: "not responding" is all an outage needs. */
reason: string | null
body: GrabBookRequestPayload
delivery: DownloadDelivery
} }
/** The refusal travels back with the call it belongs to, so one grab cannot describe another. */ /** The refusal travels back with the call it belongs to, so one grab cannot describe another. */
@@ -27,6 +46,7 @@ export interface ReleaseGrabOutcome {
item: BookRequestItem | null item: BookRequestItem | null
reason: string | null reason: string | null
errorCode: GrabFailureCode | null errorCode: GrabFailureCode | null
errorMeta?: BookRequestFailureMeta | null
} }
export interface ReleaseGrabOptions { export interface ReleaseGrabOptions {
@@ -57,6 +77,7 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
const manualOpen = ref(false) const manualOpen = ref(false)
const refusals = ref<GrabRefusal[]>([]) const refusals = ref<GrabRefusal[]>([])
const clientProblem = ref<ClientProblem | null>(null)
/** /**
* One release at a time, from the click rather than from the request. * One release at a time, from the click rather than from the request.
* *
@@ -85,6 +106,16 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
/** A fresh search is also how an approver retries a source that was merely slow. */ /** A fresh search is also how an approver retries a source that was merely slow. */
function forgetRefusals() { function forgetRefusals() {
refusals.value = [] refusals.value = []
clientProblem.value = null
}
function clientName(meta: BookRequestFailureMeta | null | undefined): string | null {
const name = meta?.client
return typeof name === 'string' && name.trim() ? name : null
}
function clientUnavailableText(client: string | null): string {
return client ? t('bookRequests.releases.clientProblem.unavailable', { client }) : t('bookRequests.releases.clientProblem.unavailableUnnamed')
} }
/** A sent release leaves nothing to pick, so the drawer pops back to the request it belongs to, unless told otherwise. */ /** A sent release leaves nothing to pick, so the drawer pops back to the request it belongs to, unless told otherwise. */
@@ -93,9 +124,18 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
const outcome = await grab(request.value.id, holdForReview.value ? { ...body, holdForReview: true } : body) const outcome = await grab(request.value.id, holdForReview.value ? { ...body, holdForReview: true } : body)
if (!outcome.item) { if (!outcome.item) {
rememberRefusal(body, delivery, outcome.errorCode) rememberRefusal(body, delivery, outcome.errorCode)
const code = outcome.errorCode
if (isClientFailure(code)) {
const client = clientName(outcome.errorMeta)
clientProblem.value = { code, client, reason: outcome.reason, body, delivery }
const description = code === 'GRAB_CLIENT_UNAVAILABLE' ? clientUnavailableText(client) : outcome.reason
toast.error(t('bookRequests.errors.grabFailed'), description ? { description } : undefined)
return
}
toast.error(t('bookRequests.errors.grabFailed'), outcome.reason ? { description: outcome.reason } : undefined) toast.error(t('bookRequests.errors.grabFailed'), outcome.reason ? { description: outcome.reason } : undefined)
return return
} }
clientProblem.value = null
setRequest(outcome.item) setRequest(outcome.item)
manualOpen.value = false manualOpen.value = false
toast.success(t('bookRequests.toasts.grabbed')) toast.success(t('bookRequests.toasts.grabbed'))
@@ -136,6 +176,23 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
void submitGrab(payload.body) void submitGrab(payload.body)
} }
/**
* The same release again, once the client is back. Re-searching was the only way out before,
* which asked every tracker again to undo something none of them had said.
*/
async function retryClient(): Promise<void> {
const problem = clientProblem.value
if (!problem || grabbing.value) return
refusals.value = refusals.value.filter((refusal) => !isClientFailure(refusal.code))
clientProblem.value = null
grabbing.value = true
try {
await sendGrab(problem.body, problem.delivery)
} finally {
grabbing.value = false
}
}
/** Only a refusal the server classified; anything else says nothing about the other releases. */ /** Only a refusal the server classified; anything else says nothing about the other releases. */
function rememberRefusal(body: GrabBookRequestPayload, delivery: DownloadDelivery, code: GrabFailureCode | null) { function rememberRefusal(body: GrabBookRequestPayload, delivery: DownloadDelivery, code: GrabFailureCode | null) {
if (!code) return if (!code) return
@@ -161,12 +218,14 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
grabbing, grabbing,
holdForReview, holdForReview,
refusals, refusals,
clientProblem,
openManual, openManual,
closeManual, closeManual,
forgetRefusals, forgetRefusals,
submitGrab, submitGrab,
handleGrab, handleGrab,
handleManualGrab, handleManualGrab,
retryClient,
isRefused, isRefused,
refusalText, refusalText,
} }
+9
View File
@@ -8869,6 +8869,15 @@
"clientRefused": "Your download client refused an earlier release", "clientRefused": "Your download client refused an earlier release",
"clientUnavailable": "Your download client did not answer an earlier release" "clientUnavailable": "Your download client did not answer an earlier release"
}, },
"clientProblem": {
"unavailable": "{client} is not responding",
"unavailableUnnamed": "Your download client is not responding",
"unavailableHint": "Releases that use it can't be sent until it answers. Check that it is running, then try again.",
"refused": "{client} turned down the release",
"refusedUnnamed": "Your download client turned down the release",
"refusedHint": "Releases that use it can't be sent until this is fixed.",
"retry": "Try again"
},
"singleFileNote": "A release can be one book in several formats, an audiobook in parts, or an archive - all of those import as a single book. A release holding several separate books asks you which one to keep.", "singleFileNote": "A release can be one book in several formats, an audiobook in parts, or an archive - all of those import as a single book. A release holding several separate books asks you which one to keep.",
"holdForReview": "Review the download before it is filed", "holdForReview": "Review the download before it is filed",
"releasePageLabel": "{title}, release page on {indexer}", "releasePageLabel": "{title}, release page on {indexer}",
@@ -98,6 +98,26 @@ describe('fetchClient', () => {
}); });
}); });
it('says why the connection failed instead of "fetch failed"', async () => {
const refused = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8082'), { code: 'ECONNREFUSED' });
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed', { cause: refused })));
await expect(fetchClient(url, {}, 'qBittorrent')).rejects.toMatchObject({ message: 'Could not reach qBittorrent: connection refused' });
});
it('reads the cause from an aggregate of several addresses', async () => {
const cause = new AggregateError([Object.assign(new Error('connect EHOSTUNREACH'), { code: 'EHOSTUNREACH' })]);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed', { cause })));
await expect(fetchClient(url, {}, 'Deluge')).rejects.toMatchObject({ message: 'Could not reach Deluge: host unreachable' });
});
it('keeps the original message when the cause is not one it knows', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
await expect(fetchClient(url, {}, 'NZBGet')).rejects.toMatchObject({ message: 'Could not reach NZBGet: fetch failed' });
});
/** /**
* `fetch` reports a timeout and a refusal as the same opaque `TypeError`, so the abort has to be * `fetch` reports a timeout and a refusal as the same opaque `TypeError`, so the abort has to be
* told apart from every other failure here or the operator is handed "fetch failed" either way. * told apart from every other failure here or the operator is handed "fetch failed" either way.
@@ -47,12 +47,35 @@ export async function fetchClient(url: URL, init: RequestInit, label: string, sa
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
throw new ServiceUnavailableException(`${label} did not answer in time`); throw new ServiceUnavailableException(`${label} did not answer in time`);
} }
throw new ServiceUnavailableException(`Could not reach ${label}: ${error instanceof Error ? error.message : String(error)}`); throw new ServiceUnavailableException(`Could not reach ${label}: ${transportFailureText(error)}`);
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
} }
const TRANSPORT_FAILURES: Record<string, string> = {
ECONNREFUSED: 'connection refused',
ECONNRESET: 'connection reset',
ENOTFOUND: 'host not found',
EAI_AGAIN: 'host not found',
EHOSTUNREACH: 'host unreachable',
ENETUNREACH: 'network unreachable',
ETIMEDOUT: 'connection timed out',
UND_ERR_CONNECT_TIMEOUT: 'connection timed out',
};
/**
* Undici reports every transport failure as `TypeError: fetch failed` and keeps the reason on
* `cause`, sometimes inside an `AggregateError` when several addresses were tried. "fetch failed"
* is what the attempt history showed for a client that was simply not running.
*/
function transportFailureText(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const cause = error instanceof Error ? (error.cause as { code?: unknown; errors?: { code?: unknown }[] } | undefined) : undefined;
const code = cause?.code ?? cause?.errors?.[0]?.code;
return (typeof code === 'string' && TRANSPORT_FAILURES[code]) || message;
}
/** Daemon-side failures are temporary; authentication and request refusals remain durable 4xx. */ /** Daemon-side failures are temporary; authentication and request refusals remain durable 4xx. */
export function throwForClientServerError(response: Response, label: string, operation: string): void { export function throwForClientServerError(response: Response, label: string, operation: string): void {
if (response.status >= 500) throw new ServiceUnavailableException(`${label} answered ${response.status} for ${operation}`); if (response.status >= 500) throw new ServiceUnavailableException(`${label} answered ${response.status} for ${operation}`);
@@ -228,7 +228,7 @@ describe('RequestFulfillmentService.grab', () => {
await expect(service.grab(7, { magnet: MAGNET }, user())).rejects.toMatchObject({ await expect(service.grab(7, { magnet: MAGNET }, user())).rejects.toMatchObject({
status: 503, status: 503,
response: { errorCode: 'GRAB_CLIENT_UNAVAILABLE' }, response: { errorCode: 'GRAB_CLIENT_UNAVAILABLE', errorMeta: { client: 'qbit' } },
}); });
expect(adapter.add).toHaveBeenCalledTimes(1); expect(adapter.add).toHaveBeenCalledTimes(1);
@@ -323,10 +323,12 @@ export class RequestFulfillmentService {
`[book_request.grab] [fail] requestId=${requestId} downloadId=${download.id} clientId=${client?.id ?? 'direct'} error="${sanitizeLogValue(message)}" - the download could not be started`, `[book_request.grab] [fail] requestId=${requestId} downloadId=${download.id} clientId=${client?.id ?? 'direct'} error="${sanitizeLogValue(message)}" - the download could not be started`,
); );
// Which half refused decides what another attempt should do: a client that is down refuses // Which half refused decides what another attempt should do: a client that is down refuses
// every torrent alike, while a file BookOrbit fetches itself was refused by the source. // every torrent alike, while a file BookOrbit fetches itself was refused by the source. The
// client's configured name lets the picker say which one, in the reader's language.
throw withGrabCode( throw withGrabCode(
error, error,
client === null ? 'GRAB_SOURCE_REFUSED' : isServiceUnavailable(error) ? 'GRAB_CLIENT_UNAVAILABLE' : 'GRAB_CLIENT_REFUSED', client === null ? 'GRAB_SOURCE_REFUSED' : isServiceUnavailable(error) ? 'GRAB_CLIENT_UNAVAILABLE' : 'GRAB_CLIENT_REFUSED',
client === null ? undefined : { client: client.name },
); );
} }
@@ -784,7 +786,7 @@ export class RequestFulfillmentService {
} }
const client = await this.clients.findOne(requestedId); const client = await this.clients.findOne(requestedId);
if (!client.enabled) throw grabError('GRAB_CLIENT_REFUSED', `Download client "${client.name}" is disabled`); if (!client.enabled) throw grabError('GRAB_CLIENT_REFUSED', `Download client "${client.name}" is disabled`, { client: client.name });
this.assertClientCanDeliver(client, source); this.assertClientCanDeliver(client, source);
this.assertClientCanImport(client); this.assertClientCanImport(client);
return client; return client;
@@ -883,16 +885,16 @@ function errorCodeOf(error: unknown): string {
return typeof code === 'string' ? code : 'UNKNOWN'; return typeof code === 'string' ? code : 'UNKNOWN';
} }
function grabError(errorCode: GrabFailureCode, message: string): BadRequestException { function grabError(errorCode: GrabFailureCode, message: string, errorMeta?: BookRequestFailureMeta): BadRequestException {
return new BadRequestException({ message, errorCode, statusCode: HttpStatus.BAD_REQUEST }); return new BadRequestException({ message, errorCode, ...(errorMeta ? { errorMeta } : {}), statusCode: HttpStatus.BAD_REQUEST });
} }
function grabUnavailable(errorCode: GrabFailureCode, message: string): ServiceUnavailableException { function grabUnavailable(errorCode: GrabFailureCode, message: string, errorMeta?: BookRequestFailureMeta): ServiceUnavailableException {
return new ServiceUnavailableException({ message, errorCode, statusCode: HttpStatus.SERVICE_UNAVAILABLE }); return new ServiceUnavailableException({ message, errorCode, ...(errorMeta ? { errorMeta } : {}), statusCode: HttpStatus.SERVICE_UNAVAILABLE });
} }
function grabConflict(errorCode: GrabFailureCode, message: string): ConflictException { function grabConflict(errorCode: GrabFailureCode, message: string, errorMeta?: BookRequestFailureMeta): ConflictException {
return new ConflictException({ message, errorCode, statusCode: HttpStatus.CONFLICT }); return new ConflictException({ message, errorCode, ...(errorMeta ? { errorMeta } : {}), statusCode: HttpStatus.CONFLICT });
} }
/** /**
@@ -914,14 +916,14 @@ export class RequestAlreadyClaimedException extends ConflictException {
* Anything that is not an HTTP failure is handed back untouched: an unexpected error is a fault * Anything that is not an HTTP failure is handed back untouched: an unexpected error is a fault
* on our side, and dressing it as a refusal would let the failover route around a real bug. * on our side, and dressing it as a refusal would let the failover route around a real bug.
*/ */
function withGrabCode(error: unknown, errorCode: GrabFailureCode): unknown { function withGrabCode(error: unknown, errorCode: GrabFailureCode, errorMeta?: BookRequestFailureMeta): unknown {
if (!(error instanceof HttpException)) return error; if (!(error instanceof HttpException)) return error;
const status = error.getStatus(); const status = error.getStatus();
if (status === (HttpStatus.SERVICE_UNAVAILABLE as number)) return grabUnavailable(errorCode, error.message); if (status === (HttpStatus.SERVICE_UNAVAILABLE as number)) return grabUnavailable(errorCode, error.message, errorMeta);
if (status === (HttpStatus.CONFLICT as number)) return grabConflict(errorCode, error.message); if (status === (HttpStatus.CONFLICT as number)) return grabConflict(errorCode, error.message, errorMeta);
if (status === (HttpStatus.BAD_REQUEST as number)) return grabError(errorCode, error.message); if (status === (HttpStatus.BAD_REQUEST as number)) return grabError(errorCode, error.message, errorMeta);
return new HttpException({ message: error.message, errorCode, statusCode: status }, status); return new HttpException({ message: error.message, errorCode, ...(errorMeta ? { errorMeta } : {}), statusCode: status }, status);
} }
function isServiceUnavailable(error: unknown): boolean { function isServiceUnavailable(error: unknown): boolean {