fix(translate): disable yandex provider while upstream relay is down (#3765)

The translate.toil.cc relay that backs the yandex provider is currently
unavailable. Introduce a `disabled` flag on TranslationProvider and
filter disabled providers out of both `getTranslators()` and
`getTranslator()` so the settings panel, translator popup, and the
fallback logic in `useTranslator` all agree the provider doesn't exist —
no per-callsite changes needed. Flip the flag back (or delete the line)
on `yandexProvider` to re-enable once the upstream is healthy.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-06 01:48:25 +08:00
committed by GitHub
parent b679817fce
commit 298d4872a0
8 changed files with 181 additions and 35 deletions
@@ -42,6 +42,18 @@ vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
// Stub Supabase so importing the full providers registry (which pulls in
// deepl.ts → @/utils/access → @/utils/supabase) doesn't instantiate a real
// GoTrueClient on every `vi.resetModules()` round. Without this, each test
// that dynamically imports the registry logs a "Multiple GoTrueClient
// instances" warning from the real Supabase client.
vi.mock('@/utils/supabase', () => ({
supabase: {
auth: { getSession: vi.fn().mockResolvedValue({ data: { session: null } }) },
from: vi.fn(),
},
}));
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
@@ -316,3 +328,75 @@ describe('azureProvider', () => {
expect(azureProvider.label).toBe('Azure Translator');
});
});
// ---------------------------------------------------------------------------
// Provider registry — disabled providers stay visible but unselectable
// ---------------------------------------------------------------------------
describe('provider registry disabled handling', () => {
// No `vi.resetModules()` here — these tests only inspect static provider
// metadata, so resolving the registry once is enough. Resetting between
// each test would re-evaluate the full import chain and churn module
// state for no benefit.
it('keeps yandex in getTranslators() so the UI can render it', async () => {
const { getTranslators } = await import('@/services/translators/providers');
const names = getTranslators().map((t) => t.name);
expect(names).toContain('yandex');
});
it('exposes yandex as disabled so callers can grey it out', async () => {
const { getTranslator } = await import('@/services/translators/providers');
const yandex = getTranslator('yandex');
expect(yandex).toBeDefined();
expect(yandex!.disabled).toBe(true);
});
it('isTranslatorAvailable returns false for disabled providers', async () => {
const { getTranslator, isTranslatorAvailable } =
await import('@/services/translators/providers');
const yandex = getTranslator('yandex')!;
expect(isTranslatorAvailable(yandex, true)).toBe(false);
expect(isTranslatorAvailable(yandex, false)).toBe(false);
});
it('isTranslatorAvailable returns false for authRequired without token', async () => {
const { isTranslatorAvailable } = await import('@/services/translators/providers');
const authed = { name: 'x', label: 'X', authRequired: true, translate: async () => [] };
expect(isTranslatorAvailable(authed, false)).toBe(false);
expect(isTranslatorAvailable(authed, true)).toBe(true);
});
it('isTranslatorAvailable returns false when quota is exceeded', async () => {
const { isTranslatorAvailable } = await import('@/services/translators/providers');
const exhausted = { name: 'x', label: 'X', quotaExceeded: true, translate: async () => [] };
expect(isTranslatorAvailable(exhausted, true)).toBe(false);
});
it('getTranslatorDisplayLabel appends a Unavailable suffix for disabled providers', async () => {
const { getTranslator, getTranslatorDisplayLabel } =
await import('@/services/translators/providers');
const yandex = getTranslator('yandex')!;
const label = getTranslatorDisplayLabel(yandex, true, (s) => s);
expect(label).toBe('Yandex Translate (Unavailable)');
});
it('getTranslatorDisplayLabel prefers the disabled suffix over other statuses', async () => {
const { getTranslatorDisplayLabel } = await import('@/services/translators/providers');
const both = {
name: 'x',
label: 'X',
disabled: true,
authRequired: true,
quotaExceeded: true,
translate: async () => [],
};
expect(getTranslatorDisplayLabel(both, false, (s) => s)).toBe('X (Unavailable)');
});
it('getTranslatorDisplayLabel returns the plain label for healthy providers', async () => {
const { getTranslator, getTranslatorDisplayLabel } =
await import('@/services/translators/providers');
const google = getTranslator('google')!;
expect(getTranslatorDisplayLabel(google, true, (s) => s)).toBe('Google Translate');
});
});
@@ -6,7 +6,12 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATOR_LANGS } from '@/services/constants';
import { UseTranslatorOptions, getTranslators } from '@/services/translators';
import {
UseTranslatorOptions,
getTranslatorDisplayLabel,
getTranslators,
isTranslatorAvailable,
} from '@/services/translators';
import Select from '@/components/Select';
const notSupportedLangs = [''];
@@ -31,6 +36,7 @@ interface TranslatorPopupProps {
interface TranslatorType {
name: string;
label: string;
disabled: boolean;
}
const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
@@ -71,9 +77,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const requestedProvider = event.target.value;
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token));
const selectedTranslator =
availableTranslators.find((t) => t.name === requestedProvider) || availableTranslators[0]!;
if (selectedTranslator) {
@@ -84,15 +88,11 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
};
useEffect(() => {
const availableProviders = translators.map((t) => {
let label = t.label;
if (t.authRequired && !token) {
label = `${label} (${_('Login Required')})`;
} else if (t.quotaExceeded) {
label = `${label} (${_('Quota Exceeded')})`;
}
return { name: t.name, label };
});
const availableProviders = translators.map((t) => ({
name: t.name,
label: getTranslatorDisplayLabel(t, !!token, _),
disabled: !!t.disabled,
}));
setProviders(availableProviders);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translators]);
@@ -214,7 +214,11 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100'
value={provider}
onChange={handleProviderChange}
options={providers.map(({ name: value, label }) => ({ value, label }))}
options={providers.map(({ name: value, label, disabled }) => ({
value,
label,
disabled,
}))}
/>
</div>
</Popup>
+4 -2
View File
@@ -4,6 +4,7 @@ import React from 'react';
type Option = {
value: string;
label: string;
disabled?: boolean;
};
type SelectProps = {
@@ -28,6 +29,7 @@ export default function Select({
onKeyDown={(e) => e.stopPropagation()}
className={clsx(
'select bg-base-200 h-8 min-h-8 max-w-[60%] truncate rounded-md border-none text-sm',
'focus:outline-none focus:ring-0 focus-visible:outline-none',
className,
)}
disabled={disabled}
@@ -35,8 +37,8 @@ export default function Select({
textAlignLast: 'end',
}}
>
{options.map(({ value, label }) => (
<option key={value} value={value}>
{options.map(({ value, label, disabled: optionDisabled }) => (
<option key={value} value={value} disabled={optionDisabled}>
{label}
</option>
))}
@@ -6,7 +6,11 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { saveViewSettings } from '@/helpers/settings';
import { getTranslators } from '@/services/translators';
import {
getTranslatorDisplayLabel,
getTranslators,
isTranslatorAvailable,
} from '@/services/translators';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { TRANSLATED_LANGS, TRANSLATOR_LANGS } from '@/services/constants';
import { ConvertChineseVariant } from '@/types/book';
@@ -80,25 +84,19 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
};
const getTranslationProviderOptions = () => {
const translators = getTranslators();
const availableProviders = translators.map((t) => {
let label = t.label;
if (t.authRequired && !token) {
label = `${label} (${_('Login Required')})`;
} else if (t.quotaExceeded) {
label = `${label} (${_('Quota Exceeded')})`;
}
return { value: t.name, label };
});
return availableProviders;
return getTranslators().map((t) => ({
value: t.name,
label: getTranslatorDisplayLabel(t, !!token, _),
// Providers marked `disabled` (e.g. upstream relay is down) stay in the
// dropdown so users can see them, but cannot be selected.
disabled: !!t.disabled,
}));
};
const getCurrentTranslationProviderOption = () => {
const value = translationProvider;
const allProviders = getTranslationProviderOptions();
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token));
const currentProvider = availableTranslators.find((t) => t.name === value)
? value
: availableTranslators[0]?.name;
+8 -4
View File
@@ -1,6 +1,12 @@
import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { ErrorCodes, getTranslator, getTranslators, TranslatorName } from '@/services/translators';
import {
ErrorCodes,
getTranslator,
getTranslators,
isTranslatorAvailable,
TranslatorName,
} from '@/services/translators';
import { getFromCache, storeInCache, UseTranslatorOptions } from '@/services/translators';
import { polish, preprocess } from '@/services/translators';
import { eventDispatcher } from '@/utils/event';
@@ -26,9 +32,7 @@ export function useTranslator({
}, [provider, sourceLang, targetLang]);
useEffect(() => {
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token));
const selectedTranslator =
availableTranslators.find((t) => t.name === provider) || availableTranslators[0]!;
const selectedProviderName = selectedTranslator.name as TranslatorName;
@@ -38,3 +38,45 @@ export const getTranslator = (name: TranslatorName): TranslationProvider | undef
export const getTranslators = (): TranslationProvider[] => {
return availableTranslators;
};
/**
* Single source of truth for "can this provider actually be used right now?".
* Used by auto-selection / fallback logic in `useTranslator`, the settings
* panel, and the translator popup. Disabled providers (e.g. temporarily down
* upstream services) are still returned from `getTranslators()` so the UI can
* render them greyed out, but this predicate excludes them so they can never
* be chosen or fallen back to.
*/
export const isTranslatorAvailable = (
translator: TranslationProvider,
hasToken: boolean,
): boolean => {
if (translator.disabled) return false;
if (translator.quotaExceeded) return false;
if (translator.authRequired && !hasToken) return false;
return true;
};
/**
* Builds the user-facing dropdown label for a provider, appending a short
* status suffix when the provider is unavailable. Kept next to
* `isTranslatorAvailable` so the two stay in sync when a new unavailability
* reason is added. The `_` translation function is passed in so this module
* stays free of React imports.
*/
export const getTranslatorDisplayLabel = (
translator: TranslationProvider,
hasToken: boolean,
_: (key: string) => string,
): string => {
if (translator.disabled) {
return `${translator.label} (${_('Unavailable')})`;
}
if (translator.authRequired && !hasToken) {
return `${translator.label} (${_('Login Required')})`;
}
if (translator.quotaExceeded) {
return `${translator.label} (${_('Quota Exceeded')})`;
}
return translator.label;
};
@@ -49,6 +49,10 @@ export const yandexProvider: TranslationProvider = {
name: 'yandex',
label: _('Yandex Translate'),
authRequired: false,
// The upstream translate.toil.cc relay is currently down. Keep the
// implementation in tree so we can re-enable it simply by flipping this
// flag to `false` (or deleting the line) once the service is healthy.
disabled: true,
translate: async (texts: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
if (!texts.length) return [];
@@ -5,6 +5,14 @@ export interface TranslationProvider {
label: string;
authRequired?: boolean;
quotaExceeded?: boolean;
/**
* Marks a provider as temporarily unavailable. Disabled providers are
* filtered out of `getTranslators()` / `getTranslator()`, so the UI never
* lists them and the fallback logic in `useTranslator` skips over them.
* Flip back to `false` (or delete the field) once the provider is healthy
* again — no other code changes required.
*/
disabled?: boolean;
translate: (
texts: string[],
sourceLang: string,