koala73/worldmonitor · warning · ValidationError
must be a lowercase two-letter language code
Error message
must be a lowercase two-letter language code
What it means
listFeedDigest validates the req.lang parameter as a lowercase two-letter ISO language code. Values that are undefined/empty default to 'en', but any other value failing /^[a-z]{2}$/ (wrong length, uppercase, digits, non-string) throws this ValidationError naming the offending field.
Solutions
- Send a lowercase two-letter ISO 639-1 code, e.g. lang='en'
- Normalize client-side: locale.split('-')[0].toLowerCase() before calling the API
- Omit lang (or pass empty string) to accept the 'en' default
- If you need broader locales, extend the validation and cache key scheme in list-feed-digest to accept them
Example fix
// before
await api.listFeedDigest({ variant: 'brief', lang: 'en-US' });
// after
const lang = 'en-US'.split('-')[0].toLowerCase();
await api.listFeedDigest({ variant: 'brief', lang }); Defensive patterns
Strategy: validation
Validate before calling
const ISO_LANG = /^[a-z]{2}$/;
if (lang !== undefined && lang !== '' && !(typeof lang === 'string' && ISO_LANG.test(lang))) {
throw new Error("lang must be a lowercase two-letter language code");
} Type guard
function isLangCode(v: unknown): v is string {
return typeof v === 'string' && /^[a-z]{2}$/.test(v);
} Try / catch
try {
const digest = await listFeedDigest({ variant: 'full', lang });
} catch (e) {
if (e instanceof ValidationError && e.issues?.some(i => i.field === 'lang')) {
digest = await listFeedDigest({ variant: 'full', lang: 'en' });
} else throw e;
} Prevention
- Normalize locales to the 2-letter subtag client-side: locale.split('-')[0].toLowerCase()
- Coerce to lowercase before sending ('EN' -> 'en')
- Omit lang entirely when you want the 'en' default
When it happens
Trigger: Calling listFeedDigest with lang like 'EN', 'eng', 'e', 'en-US', '12', or a non-string value; only exact two-char lowercase codes such as 'en', 'de', 'ja' pass.
Common situations: Clients sending BCP-47 tags ('en-US', 'pt-BR'), uppercase language codes from iOS locale identifiers, full language names ('english'), or accidentally passing the browser's full locale string instead of the 2-letter subtag.
Understand the failure class
Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.
Related errors
- provide exactly one of country_code, preset, or members.
- ${label} HTTP 400
- Could not resolve ${JSON.stringify(echoCountryInput(raw))} t
- INCOMPATIBLE_DELIVERY
- COUNTRIES_LIMIT_EXCEEDED
AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15).
Data as JSON: /api/errors/d51553500179169b.
Report an issue: GitHub.
Appendix: source
Thrown at server/worldmonitor/news/v1/list-feed-digest.ts:1891
level: LEVEL_TO_PROTO[item.level],
category: item.category,
confidence: item.confidence,
source: item.classSource,
},
locationName: '',
snippet: item.description ?? '',
tickers: item.tickers ?? [],
};
}
export async function listFeedDigest(
ctx: ServerContext,
req: ListFeedDigestRequest,
): Promise<ListFeedDigestResponse> {
const variant = VALID_VARIANTS.has(req.variant) ? req.variant : 'full';
const lang = req.lang === undefined || req.lang === '' ? 'en' : req.lang;
if (typeof lang !== 'string' || lang.length !== 2 || !/^[a-z]{2}$/.test(lang)) {
throw new ValidationError([{ field: 'lang', description: 'must be a lowercase two-letter language code' }]);
}
const digestCacheKey = `news:digest:v1:${variant}:${lang}`;
const fallbackKey = `${variant}:${lang}`;
const requestStart = Date.now();
const attemptedAt = new Date(requestStart).toISOString();
const responseDeadlineAt = requestStart + RESPONSE_DEADLINE_MS;
// Wall-clock budget for optional tail work. The build alone can consume
// ~19s worst case (14s fetcher timeout + a 5s sentinel write inside the
// cache wrapper); every awaited Redis op after it must fit inside the 25s
// Edge response ceiling minus a guard band.
// ONE revocation read per request, started at t=0 so its worst case
// overlaps the build instead of stacking after it. Shared by the fresh
// serve path and both replay tiers.
const revokedPromise = readRevokedUrlSet();
// #7085: an empty response still carries an explicit `unavailable`
// coverage block so clients can distinguish "nothing served" fromView on GitHub (pinned to 7d06c8633d)