diff --git a/admin/app/jobs/run_download_job.ts b/admin/app/jobs/run_download_job.ts index 382ab52..4062134 100644 --- a/admin/app/jobs/run_download_job.ts +++ b/admin/app/jobs/run_download_job.ts @@ -1,7 +1,7 @@ import { Job, UnrecoverableError } from 'bullmq' import { RunDownloadJobParams, DownloadProgressData } from '../../types/downloads.js' import { QueueService } from '#services/queue_service' -import { doResumableDownload } from '../utils/downloads.js' +import { doResumableDownload, GatedContentAuthError } from '../utils/downloads.js' import { createHash } from 'crypto' import { DockerService } from '#services/docker_service' import { ZimService } from '#services/zim_service' @@ -316,6 +316,13 @@ export class RunDownloadJob { if (userCancelled || abortController.signal.reason === 'user-cancel') { throw new UnrecoverableError(`Download cancelled: ${error.message}`) } + // A rejected entitlement is permanent - this build either has the key or it + // doesn't. Left as a plain Error it consumes all 10 attempts with exponential + // backoff from 30s, so the job reads `delayed` for ~4h15m and the user sees a + // stuck download instead of the message above. + if (error instanceof GatedContentAuthError) { + throw new UnrecoverableError(error.message) + } throw error } finally { if (cancelPollInterval !== null) { diff --git a/admin/app/utils/downloads.ts b/admin/app/utils/downloads.ts index 9b269f9..51a2872 100644 --- a/admin/app/utils/downloads.ts +++ b/admin/app/utils/downloads.ts @@ -10,6 +10,21 @@ import { rename } from 'fs/promises' import path from 'path' import logger from '@adonisjs/core/services/logger' +/** + * A gated source rejected this install's credentials (401/403). + * + * Permanent by nature: whether the entitlement key is baked in is a property of + * the build, so no amount of retrying changes the answer. Declared here rather + * than thrown as an UnrecoverableError directly so this module stays free of a + * BullMQ dependency — RunDownloadJob translates it at the queue boundary. + */ +export class GatedContentAuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'GatedContentAuthError' + } +} + // Some upstream mirrors reject requests with a missing or generic User-Agent. // Notably, download.kiwix.org routes the large Wikimedia-family ZIMs (Wikipedia, // Wikiversity, Wikibooks — including the flagship full Wikipedia) to @@ -73,7 +88,7 @@ export async function doResumableDownload({ // failedReason is surfaced verbatim on the downloads UI. const status = error?.response?.status if (status === 401 || status === 403) { - throw new Error( + throw new GatedContentAuthError( 'This content is hosted by Project NOMAD and requires an official release build. ' + `The download server rejected this install's credentials (HTTP ${status}).` )