Skip to content

fix(seed-imf): auth-optional Ocp-Apim-Subscription-Key + 4xx-non-retryable guard#3635

Merged
koala73 merged 2 commits into
mainfrom
worktree-iterative-seeking-kernighan
May 9, 2026
Merged

fix(seed-imf): auth-optional Ocp-Apim-Subscription-Key + 4xx-non-retryable guard#3635
koala73 merged 2 commits into
mainfrom
worktree-iterative-seeking-kernighan

Conversation

@koala73

@koala73 koala73 commented May 9, 2026

Copy link
Copy Markdown
Owner

Summary

seed-bundle-market-backup SIGTERM'd at 180s on 2026-05-09 because every IMF IRFCL request 401'd and withRetry's exponential backoff burned the whole bundle budget. Investigation revealed two compounding issues — fixing both:

  • IMF migrated api.imf.org onto Azure APIM (~2025-Q1) and is rolling out hard 401 enforcement intermittently. We captured a multi-hour enforcement window today (verified across three IPs incl. production), then verified the rollback within ~3 hours. Code needs to survive both states.
  • withRetry had no concept of permanent failures — every 4xx (auth, permission, missing config) burned the full retry budget exactly like a transient 5xx. Without a nonRetryable short-circuit, the next enforcement window will SIGTERM the bundle again even with a valid key.

The gateway's WWW-Authenticate: Bearer 401 is a misdirection — actual auth scheme is Ocp-Apim-Subscription-Key (verified: Bearer → 401, APIM key → 200, query param ?subscription-key= also → 200). One subscription unlocks both SDMX 2.1 and 3.0.

Changes

  • scripts/_seed-utils.mjs

    • New imfAuthHeaders() — returns {} when IMF_API_KEY unset (preserves today's anonymous-pass-through), {Ocp-Apim-Subscription-Key: …} when set (forward-compat with permanent enforcement).
    • withRetry short-circuits on err.nonRetryable === true before sleeping. Generalises across all seeders.
    • imfSdmxFetchIndicator sends auth header + tags 4xx as nonRetryable. Covers 6 WEO seeders (growth, macro, labor, external, national-debt, recovery-fiscal-space).
  • scripts/seed-gold-cb-reserves.mjs

    • fetchIrfclMonthlySeries sends auth header + tags 4xx. Covers the seeder whose SIGTERM surfaced this incident.
    • Outdated // public, no auth header comment fixed.
  • .env.exampleIMF_API_KEY (optional, recommended) with portal URL.

Why auth-optional rather than required

IMF currently allows anonymous requests. Hard-throwing on missing IMF_API_KEY would instantly break every IMF seeder right now (they're working). Sending the header opportunistically when the env is set is forward-compatible without regressing today's state. The nonRetryable 4xx guard is the safety net for whenever IMF flips enforcement on permanently.

Operator action required (separate from merge)

Set IMF_API_KEY on every Railway service that runs an IMF-touching bundle:

  • seed-bundle-market-backup (gold-cb-reserves)
  • whichever services run WEO bundles (seed-bundle-macro and siblings — seed-imf-{growth,macro,labor,external}, seed-national-debt, seed-recovery-fiscal-space)

Get the key at https://portal.api.imf.org/ → Sign in → Products → IMF Data SDMX API → Subscribe → Profile → Subscriptions → Primary key. Free tier, 5 min, no IMF approval queue.

Test plan

  • 37/37 affected unit tests pass (gold-cb-reserves, seed-imf-extended, seed-utils-sigterm-cleanup)
  • Full data test suite: 8256/8256 pass (npm run test:data)
  • Live fetch w/o env → 200, 111 IRFCL series in 1.9s (today's anonymous-pass-through state)
  • Live fetch w/ env → 200, 208 WEO countries in 1.5s (forward-compat path)
  • Missing-env path returns {} (no nonRetryable throw — correct relaxed behavior)
  • Forced 401 (bogus key) → tagged nonRetryable, fails in <1s instead of burning the retry budget
  • npm run typecheck, typecheck:api, lint, lint:md, version:check, edge bundle, edge-functions test — all pass
  • Pre-push hook clean (boundary, sentry coverage, rate-limit, premium-fetch, seed tests)
  • After merge: set IMF_API_KEY on Railway services listed above, then watch one cycle of seed-bundle-market-backup to confirm the section completes in <10s instead of the prior 180s SIGTERM

…yable guard

seed-bundle-market-backup SIGTERM'd at 180s on 2026-05-09 because every IMF
IRFCL request 401'd and `withRetry` exponential backoff burned the whole
budget. Verified end-to-end:

  * IMF migrated api.imf.org onto Azure APIM (~2025-Q1) and is rolling out
    hard 401 enforcement intermittently — captured a multi-hour enforcement
    window today, then verified rollback within ~3h. Need code that survives
    both states.

  * The gateway's `WWW-Authenticate: Bearer` 401 is misleading; actual scheme
    is `Ocp-Apim-Subscription-Key` (verified: Bearer header → 401, APIM key
    header → 200, query param `?subscription-key=` also → 200).

Changes:

  * scripts/_seed-utils.mjs — new `imfAuthHeaders()` returns {} when
    IMF_API_KEY unset (preserves today's anonymous-pass-through) and
    {Ocp-Apim-Subscription-Key: …} when set (forward-compat with permanent
    enforcement). One subscription unlocks both SDMX 2.1 and 3.0.

  * scripts/_seed-utils.mjs — `withRetry` short-circuits on
    `err.nonRetryable === true`. Without this, the next enforcement window
    will SIGTERM the bundle again even with a valid key (a typo or quota
    exhaust = 401 = retry storm).

  * scripts/_seed-utils.mjs::imfSdmxFetchIndicator — sends auth header,
    tags 4xx as nonRetryable. Covers 6 WEO seeders (growth, macro, labor,
    external, national-debt, recovery-fiscal-space).

  * scripts/seed-gold-cb-reserves.mjs::fetchIrfclMonthlySeries — same
    treatment. Covers the seeder whose SIGTERM surfaced this incident.

  * .env.example — IMF_API_KEY (optional, recommended) with portal URL.

Verified:
  * 37/37 existing tests pass (gold-cb-reserves, seed-imf-extended,
    seed-utils-sigterm-cleanup).
  * Live fetch w/o env → 200, 111 IRFCL series in 1.9s (today's state).
  * Live fetch w/ env  → 200, 208 WEO countries in 1.5s (forward-compat).
  * Missing-env path returns {} (no nonRetryable throw — correct relaxed
    behavior).

Operator action: register at https://portal.api.imf.org/ → Products → IMF
Data SDMX API → Subscribe → Profile → Subscriptions → copy Primary key →
set IMF_API_KEY on every Railway service that runs an IMF-touching bundle
(seed-bundle-market-backup + whichever runs WEO seeders). Free tier, 5 min,
no IMF approval queue. Setting it is forward-compat; not setting it leaves
us exposed when IMF flips enforcement on permanently.
@vercel

vercel Bot commented May 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview May 9, 2026 1:57pm

Request Review

@greptile-apps

greptile-apps Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds forward-compatible IMF APIM authentication (Ocp-Apim-Subscription-Key via IMF_API_KEY) to the two IMF fetch paths and introduces a nonRetryable short-circuit in withRetry to avoid burning the full retry budget on permanent auth/permission failures.

  • _seed-utils.mjs: New imfAuthHeaders() function reads IMF_API_KEY and returns the APIM header when set, {} otherwise. withRetry gains an early throw on err.nonRetryable, and imfSdmxFetchIndicator tags 4xx responses accordingly — covering 6 WEO seeders.
  • seed-gold-cb-reserves.mjs: Same auth-header and nonRetryable tagging applied to fetchIrfclMonthlySeries, the seeder whose SIGTERM surfaced this incident.
  • .env.example: Documents the new optional IMF_API_KEY with portal URL and auth-status context.

Confidence Score: 3/5

The auth-header and fast-fail logic are sound, but the 4xx nonRetryable guard incorrectly covers 429 in both fetch paths, which would cause the seeders to give up immediately on any rate-limit response rather than retrying with backoff.

Both IMF fetch functions mark the entire 4xx band as nonRetryable, including 429 Too Many Requests. A rate-limited response from the APIM gateway would immediately abort all retry attempts — the exact failure mode this PR is trying to solve for auth errors, now re-introduced for rate limits. The fix is a one-liner in each place, but it affects both changed fetch paths and the shared withRetry behaviour that all future callers will inherit.

Both scripts/_seed-utils.mjs and scripts/seed-gold-cb-reserves.mjs carry the same r.status >= 400 && r.status < 500 guard that needs the 429 carve-out before merge.

Important Files Changed

Filename Overview
scripts/_seed-utils.mjs Adds imfAuthHeaders() helper and nonRetryable short-circuit to withRetry; the 4xx guard incorrectly marks 429 (rate limit) as a permanent failure alongside auth errors.
scripts/seed-gold-cb-reserves.mjs Integrates imfAuthHeaders() and the nonRetryable tag into fetchIrfclMonthlySeries; carries the same 429-as-permanent-failure defect as the shared utility.
.env.example Adds optional IMF_API_KEY entry with clear documentation; minor double-blank-line formatting issue before the next section.

Reviews (1): Last reviewed commit: "fix(seed-imf): auth-optional Ocp-Apim-Su..." | Re-trigger Greptile

Comment thread scripts/_seed-utils.mjs
Comment on lines +658 to +662
if (!r.ok) {
const err = new Error(`IMF SDMX ${indicator}: HTTP ${r.status}`);
if (r.status >= 400 && r.status < 500) err.nonRetryable = true;
throw err;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The >= 400 && < 500 range silently includes 429 Too Many Requests, which is a transient condition — the whole point of withRetry with backoff. If the IMF APIM gateway starts rate-limiting (a realistic scenario as adoption grows), every request would be tagged nonRetryable and fail immediately on the first attempt, discarding the retry budget the same way a 401 would. The fix that matches the PR's stated intent (guard against auth/permission failures) is to carve out 429.

Suggested change
if (!r.ok) {
const err = new Error(`IMF SDMX ${indicator}: HTTP ${r.status}`);
if (r.status >= 400 && r.status < 500) err.nonRetryable = true;
throw err;
}
if (!r.ok) {
const err = new Error(`IMF SDMX ${indicator}: HTTP ${r.status}`);
if (r.status >= 400 && r.status < 500 && r.status !== 429) err.nonRetryable = true;
throw err;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in this same commit. Line 707 of _seed-utils.mjs (HEAD = d91f790) reads if (PERMANENT_4XX_STATUSES.has(r.status)) err.nonRetryable = true; — the allowlist is {400, 401, 403, 404, 410, 422, 451}, which excludes 408 and 429.

Looks like Greptile reviewed the prior commit (74de321, which had the broad r.status >= 400 && r.status < 500 predicate) and re-anchored to the latest SHA. tests/seed-utils-with-retry.test.mjs includes an explicit regression assertion (PERMANENT_4XX_STATUSES.has(408) === false and PERMANENT_4XX_STATUSES.has(429) === false) citing this review as the guard. 429 + 503 also attach retryAfterMs so withRetry honors upstream Retry-After. Closing as resolved.

Comment on lines +73 to +77
if (!r.ok) {
const err = new Error(`IMF IRFCL ${indicator}: HTTP ${r.status}`);
if (r.status >= 400 && r.status < 500) err.nonRetryable = true;
throw err;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Same 429-as-nonRetryable issue as in imfSdmxFetchIndicatorfetchIrfclMonthlySeries will also immediately bail on a rate-limit response rather than retrying with backoff.

Suggested change
if (!r.ok) {
const err = new Error(`IMF IRFCL ${indicator}: HTTP ${r.status}`);
if (r.status >= 400 && r.status < 500) err.nonRetryable = true;
throw err;
}
if (!r.ok) {
const err = new Error(`IMF IRFCL ${indicator}: HTTP ${r.status}`);
if (r.status >= 400 && r.status < 500 && r.status !== 429) err.nonRetryable = true;
throw err;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the _seed-utils.mjs thread — line 81 here already uses PERMANENT_4XX_STATUSES.has(r.status) (commit d91f790), and on 429/503 attaches retryAfterMs from the Retry-After header. Greptile re-anchored a stale review against the post-fix SHA.

Comment thread .env.example
PR #3635 review (P1): the blanket `r.status >= 400 && r.status < 500`
predicate marked 408 Request Timeout and 429 Too Many Requests as
nonRetryable. Both are explicit "back off and retry" signals — for IMF's
APIM gateway, 429 is the most likely status under the parallel WEO
indicator fetches in seed-imf-{growth,macro,labor,external}. The previous
guard would have converted a transient rate-limit into an immediate seed
failure.

Changes:

  * scripts/_seed-utils.mjs — new `PERMANENT_4XX_STATUSES` allowlist
    (400/401/403/404/410/422/451). Only these tag nonRetryable. 408/429
    fall through to normal retry. 5xx already retryable (unchanged).

  * scripts/_seed-utils.mjs — new `parseRetryAfterMs` helper (mirrors
    the helper already duplicated in _yahoo-fetch / _gdelt-fetch /
    _open-meteo-archive — those existing copies left untouched per
    surgical-scope; consolidate-the-3-old-copies is a separate refactor).
    Caps hint at 60s so a stuck/abusive header can't park the bundle.

  * scripts/_seed-utils.mjs — `withRetry` honors `err.retryAfterMs`
    when caller attaches it. Takes max(baseWait, retryAfterMs) so a
    short hint doesn't undercut backoff and a long hint isn't ignored.

  * scripts/_seed-utils.mjs::imfSdmxFetchIndicator AND
    scripts/seed-gold-cb-reserves.mjs::fetchIrfclMonthlySeries — narrow
    the nonRetryable predicate to PERMANENT_4XX_STATUSES; on 429/503
    attach `retryAfterMs = parseRetryAfterMs(r.headers.get('retry-after'))`.

  * tests/seed-utils-with-retry.test.mjs — new dedicated test file:
    - PERMANENT_4XX_STATUSES contains expected codes
    - 408/429 EXCLUDED (explicit regression guard for the review finding)
    - 5xx EXCLUDED
    - parseRetryAfterMs: seconds, HTTP-date, null cases, 60s cap,
      1000ms floor for "0"/past-date hints (matches yahoo helper)
    - withRetry: nonRetryable short-circuit, exponential backoff for
      plain errors, success on first try, retryAfterMs honored

Verified:
  * 49/49 affected tests pass (new file + gold-cb-reserves +
    seed-imf-extended + seed-utils-sigterm-cleanup).
  * Live: 208 WEO countries fetched in 1.5s with key set.
  * The retryAfterMs-honor test takes ~200ms confirming the upstream hint
    actually overrides the default backoff (would be ~1ms otherwise).
@koala73
koala73 merged commit 2bbb95a into main May 9, 2026
11 checks passed
@koala73
koala73 deleted the worktree-iterative-seeking-kernighan branch May 9, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant