{"record":{"id":"b7b463f5d510b517","repo":"koala73/worldmonitor","slug":"iso2-must-be-a-2-letter-uppercase-iso-country-code-b7b463","errorCode":null,"errorMessage":"iso2 must be a 2-letter uppercase ISO country code","messagePattern":"iso2 must be a 2-letter uppercase ISO country code","errorType":"validation","errorClass":"ValidationError","httpStatus":400,"severity":"warning","filePath":"server/worldmonitor/supply-chain/v1/get-multi-sector-cost-shock.ts","lineNumber":80,"sourceCode":"    unavailableReason,\n  };\n}\n\nexport async function getMultiSectorCostShock(\n  ctx: ServerContext,\n  req: GetMultiSectorCostShockRequest,\n): Promise<GetMultiSectorCostShockResponse> {\n  const iso2 = (req.iso2 ?? '').trim().toUpperCase();\n  const chokepointId = (req.chokepointId ?? '').trim().toLowerCase();\n  const closureDays = clampClosureDays(req.closureDays ?? 30);\n\n  // Input-shape errors return 400 — restoring the legacy /api/supply-chain/v1/\n  // multi-sector-cost-shock contract. Empty-payload-200 is reserved for the\n  // PRO-gate deny path (intentional contract shift), not for caller bugs\n  // (malformed or missing fields). Distinguishing the two matters for external\n  // API consumers, tests, and silent-failure detection in logs.\n  if (!/^[A-Z]{2}$/.test(iso2)) {\n    throw new ValidationError([{ field: 'iso2', description: 'iso2 must be a 2-letter uppercase ISO country code' }]);\n  }\n  if (!chokepointId) {\n    throw new ValidationError([{ field: 'chokepointId', description: 'chokepointId is required' }]);\n  }\n  if (!CHOKEPOINT_REGISTRY.some(c => c.id === chokepointId)) {\n    throw new ValidationError([{ field: 'chokepointId', description: `Unknown chokepointId: ${chokepointId}` }]);\n  }\n\n  const isPro = await isCallerPremium(ctx.request);\n  if (!isPro) return emptyResponse(iso2, chokepointId, closureDays);\n\n  // Seeder writes the products payload via raw key (no env-prefix) — read raw.\n  const productsKey = `comtrade:bilateral-hs4:${iso2}:v1`;\n  const [productsCache, statusCache] = await Promise.all([\n    getCachedJson(productsKey, true).catch(() => null) as Promise<CountryProductsCache | null>,\n    getCachedJson(CHOKEPOINT_STATUS_KEY).catch(() => null) as Promise<{ chokepoints?: ChokepointInfo[] } | null>,\n  ]);\n","sourceCodeStart":62,"sourceCodeEnd":98,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/server/worldmonitor/supply-chain/v1/get-multi-sector-cost-shock.ts#L62-L98","documentation":"getMultiSectorCostShock applies the same contract as its sibling endpoints: it trims/uppercases req.iso2 and throws a 400 ValidationError when the value does not match ^[A-Z]{2}$, reserving the empty-200 shape for the PRO-gate deny path. The iso2 check is the first of three validations — chokepointId-required, then chokepointId-registry, then the PRO gate.","triggerScenarios":"Calling GetMultiSectorCostShock with iso2 missing, empty, wrong length ('U', 'DEU'), or containing non-letters ('D1', 'germany'). chokepointId defaults like closureDays do not save you — a bad iso2 throws before any other field is consulted.","commonSituations":"Reusing a country name or ISO3 code from another dataset; unset query params rendering as ''; shared request-builder code that skips the iso2 field; tests with placeholder values like 'XX1'.","solutions":["Send a 2-letter ISO-3166-1 alpha-2 code after trim + toUpperCase","Validate both iso2 (^[A-Z]{2}$) and chokepointId (present, in CHOKEPOINT_REGISTRY) client-side before the call","Pull codes from the same chokepoint/country registries the service uses"],"exampleFix":"// before\ngetMultiSectorCostShock(ctx, { iso2: 'germany', chokepointId: 'panama-canal' });\n// after\ngetMultiSectorCostShock(ctx, { iso2: 'DE', chokepointId: 'panama-canal' });","handlingStrategy":"validation","validationCode":"const iso2 = value.trim().toUpperCase();\nif (!/^[A-Z]{2}$/.test(iso2)) throw new RangeError('iso2 must be a 2-letter ISO-3166-1 alpha-2 code');\nif (!chokepointId) throw new RangeError('chokepointId is required');","typeGuard":"const isIso2 = (v: unknown): v is string => typeof v === 'string' && /^[A-Z]{2}$/.test(v.trim().toUpperCase());","tryCatchPattern":"catch (e) { const f = e?.details?.[0]?.field; if (f === 'iso2') { fix the country code and re-submit } else if (f === 'chokepointId') { supply a registry chokepointId } else throw e; }","preventionTips":["Validate iso2 and chokepointId together before the call — both 400s are caller bugs, empty 200 means PRO-gated","Source country codes from a fixed alpha-2 list, not free text","Reuse the same normalization (trim + uppercase) the server applies"],"tags":["validation","country-code","iso-3166","supply-chain","http-400"],"backgroundTag":"invalid-country-code","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-08T15:18:49.778Z"}