fix(mobile): handle RSSHub subscription limit errors

This commit is contained in:
DIYgod 2026-07-26 07:29:14 +08:00
parent 52f587dacb
commit 2350884eae
9 changed files with 126 additions and 6 deletions

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest"
import { sanitizeErrorMessage } from "./error-message"
describe("sanitizeErrorMessage", () => {
it("removes Follow API request context from display messages", () => {
const message = [
"RSSHub feed subscription limit exceeded",
"Request: POST /subscriptions (original: /subscriptions)",
"Args: {",
' "headers": {',
' "cookie": "session=secret"',
" }",
"}",
].join("\n")
expect(sanitizeErrorMessage(message)).toBe("RSSHub feed subscription limit exceeded")
})
it("supports CRLF request context", () => {
expect(
sanitizeErrorMessage("Subscription limit exceeded\r\nRequest: POST /subscriptions"),
).toBe("Subscription limit exceeded")
})
it("preserves ordinary error messages", () => {
expect(sanitizeErrorMessage("Unable to follow this feed")).toBe("Unable to follow this feed")
})
})

View File

@ -0,0 +1,4 @@
const FOLLOW_API_REQUEST_CONTEXT_PATTERN = /\r?\nRequest:[\s\S]*$/u
export const sanitizeErrorMessage = (message: string) =>
message.replace(FOLLOW_API_REQUEST_CONTEXT_PATTERN, "").trim()

View File

@ -0,0 +1,79 @@
import { FollowAPIError } from "@follow-app/client-sdk"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { toastFetchError } from "./error-parser"
const mocks = vi.hoisted(() => ({
isPaymentEnabled: false,
showUpgradeRequiredDialog: vi.fn(),
toastError: vi.fn(),
}))
vi.mock("i18next", () => ({
t: (key: string) => {
if (key === "errors:2012") {
return "RSSHub feed subscription limit exceeded"
}
return key.replace(/^errors:/u, "")
},
}))
vi.mock("@/src/atoms/server-configs", () => ({
getIsPaymentEnabled: () => mocks.isPaymentEnabled,
}))
vi.mock("@/src/modules/dialogs/UpgradeRequiredDialog", () => ({
showUpgradeRequiredDialog: mocks.showUpgradeRequiredDialog,
}))
vi.mock("./toast", () => ({
toast: {
error: mocks.toastError,
},
}))
describe("toastFetchError", () => {
beforeEach(() => {
mocks.isPaymentEnabled = false
mocks.showUpgradeRequiredDialog.mockClear()
mocks.toastError.mockClear()
})
it("shows a concise upgrade dialog for RSSHub subscription limits", () => {
mocks.isPaymentEnabled = true
const error = new FollowAPIError(
[
"RSSHub feed subscription limit exceeded",
"Request: POST /subscriptions (original: /subscriptions)",
'Args: { "headers": { "cookie": "session=secret" } }',
].join("\n"),
402,
"2012",
)
toastFetchError(error)
expect(mocks.showUpgradeRequiredDialog).toHaveBeenCalledWith({
title: "RSSHub feed subscription limit exceeded",
message: "settings:subscription.summary.free_description",
})
expect(mocks.toastError).not.toHaveBeenCalled()
})
it("does not expose request context when an API error code has no translation", () => {
const error = new FollowAPIError(
[
"Unable to follow this feed",
"Request: POST /subscriptions (original: /subscriptions)",
'Args: { "headers": { "cookie": "session=secret" } }',
].join("\n"),
400,
"29999",
)
toastFetchError(error)
expect(mocks.toastError).toHaveBeenCalledOnce()
expect(mocks.toastError).toHaveBeenCalledWith("Unable to follow this feed")
})
})

View File

@ -5,6 +5,7 @@ import { FetchError } from "ofetch"
import { getIsPaymentEnabled } from "@/src/atoms/server-configs" import { getIsPaymentEnabled } from "@/src/atoms/server-configs"
import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog" import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog"
import { sanitizeErrorMessage } from "./error-message"
import { toast } from "./toast" import { toast } from "./toast"
export const getFetchErrorInfo = ( export const getFetchErrorInfo = (
@ -21,11 +22,11 @@ export const getFetchErrorInfo = (
const i18nKey = `errors:${code}` as any const i18nKey = `errors:${code}` as any
const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey) const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)
return { return {
message: `${i18nMessage}${reason ? `: ${reason}` : ""}`, message: sanitizeErrorMessage(`${i18nMessage}${reason ? `: ${reason}` : ""}`),
code, code,
} }
} catch { } catch {
return { message: error.message } return { message: sanitizeErrorMessage(error.message) }
} }
} }
@ -35,15 +36,15 @@ export const getFetchErrorInfo = (
const i18nKey = `errors:${code}` as any const i18nKey = `errors:${code}` as any
const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey) const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)
return { return {
message: i18nMessage, message: sanitizeErrorMessage(i18nMessage),
code, code,
} }
} catch { } catch {
return { message: error.message } return { message: sanitizeErrorMessage(error.message) }
} }
} }
return { message: error.message } return { message: sanitizeErrorMessage(error.message) }
} }
export const getFetchErrorMessage = (error: Error) => { export const getFetchErrorMessage = (error: Error) => {
@ -58,7 +59,7 @@ export const createErrorToaster = (title?: string) => (err: Error) =>
toastFetchError(err, { title }) toastFetchError(err, { title })
export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => { export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => {
const { message: fallbackMessage } = error const fallbackMessage = sanitizeErrorMessage(error.message)
let message = fallbackMessage let message = fallbackMessage
let _reason = "" let _reason = ""
let code: number | undefined let code: number | undefined
@ -113,6 +114,8 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
} }
} }
message = sanitizeErrorMessage(message)
// 2fa errors are handled by the form // 2fa errors are handled by the form
if (code === 4007 || code === 4008) { if (code === 4007 || code === 4008) {
return return

View File

@ -19,6 +19,7 @@
"2004": "Feed failed to parse", "2004": "Feed failed to parse",
"2010": "Ownership challenge failed", "2010": "Ownership challenge failed",
"2011": "Subscription limit exceeded", "2011": "Subscription limit exceeded",
"2012": "RSSHub feed subscription limit exceeded",
"3000": "Entry not found", "3000": "Entry not found",
"4000": "Already claimed", "4000": "Already claimed",
"4001": "User wallet error", "4001": "User wallet error",

View File

@ -19,6 +19,7 @@
"2004": "Échec de l'analyse du flux", "2004": "Échec de l'analyse du flux",
"2010": "Échec de la vérification de propriété", "2010": "Échec de la vérification de propriété",
"2011": "Limite d'abonnement dépassée", "2011": "Limite d'abonnement dépassée",
"2012": "Limite d'abonnement aux flux RSSHub dépassée",
"3000": "Entrée introuvable", "3000": "Entrée introuvable",
"4000": "Déjà réclamé", "4000": "Déjà réclamé",
"4001": "Erreur de portefeuille utilisateur", "4001": "Erreur de portefeuille utilisateur",

View File

@ -19,6 +19,7 @@
"2004": "フィードの解析に失敗しました", "2004": "フィードの解析に失敗しました",
"2010": "所有権の確認に失敗しました", "2010": "所有権の確認に失敗しました",
"2011": "購読制限を超えました", "2011": "購読制限を超えました",
"2012": "RSSHubフィードの購読数制限を超えました",
"3000": "エントリが見つかりません", "3000": "エントリが見つかりません",
"4000": "すでに請求されています", "4000": "すでに請求されています",
"4001": "ユーザーウォレットエラー", "4001": "ユーザーウォレットエラー",

View File

@ -19,6 +19,7 @@
"2004": "订阅源解析失败", "2004": "订阅源解析失败",
"2010": "所有权挑战失败", "2010": "所有权挑战失败",
"2011": "超出订阅限制", "2011": "超出订阅限制",
"2012": "RSSHub 订阅源数量限制已超出",
"3000": "未找到条目", "3000": "未找到条目",
"4000": "已被认领", "4000": "已被认领",
"4001": "用户钱包错误", "4001": "用户钱包错误",

View File

@ -19,6 +19,7 @@
"2004": "訂閱源解析失敗", "2004": "訂閱源解析失敗",
"2010": "所有權挑戰失敗", "2010": "所有權挑戰失敗",
"2011": "超過訂閱限制", "2011": "超過訂閱限制",
"2012": "RSSHub 訂閱源數量限制已超過",
"3000": "條目未找到", "3000": "條目未找到",
"4000": "已被認領", "4000": "已被認領",
"4001": "用戶錢包錯誤", "4001": "用戶錢包錯誤",