koala73/worldmonitor · error · ValidationError

hs4 must be a supported four-digit heading

Error message

hs4 must be a supported four-digit heading

What it means

getCountryProducts accepts an optional `hs4` filter restricted to a fixed allowlist of supported four-digit HS headings (HS4_CODES). If hs4 is provided but is not in that list, a ValidationError with field 'hs4' is thrown; note the endpoint also returns an empty product list for non-premium callers.

Solutions

  1. Check hs4 against the supported HS4_CODES list (fetch the unfiltered products response to see which headings are returned) and pick a supported one
  2. Normalize input: trim and pass exactly four characters, digits only (e.g. '8501', not '8501.10' or ' 8501 ')
  3. Omit hs4 entirely to get all tracked products for the country, then filter client-side
  4. If you need an untracked heading, request it be added to HS4_CODES rather than sending it to the endpoint

Example fix

// before
await client.getCountryProducts({ iso2: 'DE', hs4: '8501.10' });
// after
const hs4 = req.hs4?.trim();
if (hs4 && !/^\d{4}$/.test(hs4)) throw new Error('hs4 must be 4 digits');
await client.getCountryProducts({ iso2: 'DE', hs4 }); // or omit hs4 and filter locally
Defensive patterns

Strategy: validation

Validate before calling

const SUPPORTED_HS4 = ['8501','8471','5201']; // mirror of HS4_CODES / from API response
if (hs4 !== undefined && hs4 !== '' && !SUPPORTED_HS4.includes(hs4.trim())) {
  throw new Error(`Unsupported hs4 heading: ${hs4}`);
}

Type guard

function isSupportedHs4(v: string): boolean {
  return /^\d{4}$/.test(v) && HS4_CODES.includes(v);
}

Try / catch

try {
  return await client.getCountryProducts({ iso2, hs4 });
} catch (e) {
  if (e instanceof ValidationError && e.fields?.[0]?.field === 'hs4') {
    return client.getCountryProducts({ iso2 }); // all tracked products, filter client-side
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling get-country-products with hs4 = '12345' (5 digits), '85' (2-digit chapter), a lowercase or padded value, or a valid-looking HS4 code that the platform simply does not track.

Common situations: Confusing HS chapters (2-digit) or lines (6+ digit) with 4-digit headings; using a code valid in the full HS taxonomy but absent from the tracked subset; trailing spaces from spreadsheet data; non-premium callers probing the parameter expecting data.

Understand the failure class

Background: Invalid enum value errors: "Unknown type", "Invalid scope", "must be one of" — when a string is not on the library's allowed list — this error's family across 23 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/3182eb01a0f10fbf. Report an issue: GitHub.

Appendix: source

Thrown at server/worldmonitor/supply-chain/v1/get-country-products.ts:209

    ...product,
    topExporters: product.topExporters.map(exporter => {
      const scale = byCode.get(exporter.partnerCode);
      return scale && scale.year === product.year ? { ...exporter, scale } : exporter;
    }),
  };
}

export async function getCountryProducts(
  ctx: ServerContext,
  req: GetCountryProductsRequest,
): Promise<GetCountryProductsResponse> {
  const iso2 = (req.iso2 ?? '').trim().toUpperCase();
  if (!/^[A-Z]{2}$/.test(iso2)) {
    throw new ValidationError([{ field: 'iso2', description: 'iso2 must be a 2-letter uppercase ISO country code' }]);
  }
  const hs4 = req.hs4?.trim();
  if (hs4 && !HS4_CODES.includes(hs4)) {
    throw new ValidationError([{ field: 'hs4', description: 'hs4 must be a supported four-digit heading' }]);
  }
  const isPro = await isCallerPremium(ctx.request);
  const empty: GetCountryProductsResponse = { iso2, products: [], fetchedAt: '' };
  if (!isPro) return empty;

  const key = `comtrade:bilateral-hs4:${iso2}:v1`;
  // Status-aware reads for the two country keys so a read error stays
  // distinguishable from a miss; the canonical one decides cache_unavailable,
  // the sibling one only decides how deep the origins go.
  // The sibling detail and the world-exports snapshot are served only for a
  // requested heading, so a whole-catalogue caller (the deep-dive panel) does
  // not read them: the snapshot is a few hundred kilobytes (36 headings x ~140
  // reporters), past what the 1.5 s single-GET deadline is sized for, and the
  // large-value reader waits on the pipeline deadline instead.
  const [cached, siblingRead, worldExportsValue, meta] = await Promise.all([
    readCachedJson(key, true),
    hs4 ? readCachedJson(PARTNERS_KEY(iso2), true) : { status: 'miss' as const },
    hs4 ? getLargeRawJson(WORLD_EXPORTS_KEY).catch(() => null) : null,

View on GitHub (pinned to 7d06c8633d)