-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathimf-client.ts
More file actions
769 lines (720 loc) · 29.6 KB
/
imf-client.ts
File metadata and controls
769 lines (720 loc) · 29.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
/**
* @module IMF/Client
* @description TypeScript REST client for IMF public data APIs.
*
* Covers two transports, both public and unauthenticated:
*
* 1. **Datamapper JSON** (`https://www.imf.org/external/datamapper/api/v1`)
* — simple JSON, best for World Economic Outlook (WEO) headline
* indicators and projections. Matches the ergonomics of our existing
* `world-bank-client.ts` pattern.
*
* 2. **SDMX 3.0** (`https://api.imf.org/external/sdmx/3.0`) — full IMF
* catalogue (IFS, BOP, GFS_COFOG, FM, MFS_*, FSIC, DOTS, PCPS). The
* `sdmxFetch()` method is a thin passthrough for callers that need
* broader coverage than the Datamapper WEO surface.
*
* The client mirrors the safety posture of `world-bank-client.ts`:
* - deterministic timeouts
* - exponential back-off on 5xx / 429
* - no credentials stored or transmitted (all IMF data is public)
*
* Rate-limit discipline: IMF advertises ~10 requests / 5 s. The client
* defaults to `maxRetries=2` and delays 1 s on the first retry, 2 s on
* the second; consumers that batch in tight loops should additionally
* insert their own cooperative throttling.
*
* @author Hack23 AB
* @license Apache-2.0
* @see https://data.imf.org/api/documentation
*/
import { toDatamapperCode } from './imf-codes.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* A single IMF data point. Shape mirrors `WorldBankDataPoint` so that the
* provider-agnostic `economic-context` helpers can consume either source
* interchangeably.
*/
export interface ImfDataPoint {
readonly countryCode: string;
readonly countryName: string;
readonly indicatorId: string;
readonly indicatorName: string;
readonly date: string;
readonly value: number;
/** True when the value is a projection (future year in the release vintage). */
readonly projection: boolean;
/** Release vintage tag (e.g. 'WEO-2026-04'). Present for projection-bearing releases. */
readonly projectionVintage?: string;
/** Provider tag — always 'imf' for this client. */
readonly provider: 'imf';
}
/** Client configuration */
export interface ImfClientConfig {
/** Override for the Datamapper base URL (for testing). */
readonly datamapperBaseURL?: string;
/** Override for the SDMX 3.0 base URL (for testing). */
readonly sdmxBaseURL?: string;
/** Request timeout in ms. Default 15_000. */
readonly timeout?: number;
/**
* User-Agent sent to IMF HTTP endpoints. Akamai currently rejects Node's
* default undici user-agent on the Datamapper API with HTTP 403, while
* browser/curl-style user-agents succeed.
*/
readonly userAgent?: string;
/** Max retry count for transient failures. Default 2. */
readonly maxRetries?: number;
/**
* Optional WEO vintage tag to stamp on every projection returned by
* `getWeoIndicator`. Defaults to the current WEO cycle — update in
* April / October when the IMF publishes a new flagship release.
*/
readonly weoVintage?: string;
/**
* IMF Data SDMX API subscription key, sent as the
* `Ocp-Apim-Subscription-Key` header on every {@link ImfClient.sdmxFetch}
* call. The Datamapper transport (`getWeoIndicator` etc.) is NOT
* authenticated and never receives this header.
*
* If omitted the client falls back to `process.env.IMF_SDMX_SUBSCRIPTION_KEY`.
* If neither is set, SDMX requests still go out (so connectivity probes can
* detect a "no key configured" state) but the IMF Azure APIM gateway will
* mask `/data/...` endpoints as **HTTP 404 "Resource not found"** when the
* `Ocp-Apim-Subscription-Key` header is absent (verified via curl
* 2026-05-10). When an *invalid* key is sent, APIM returns **401/403**
* instead. {@link ImfHttpError} surfaces both modes with a single
* "subscription key missing or invalid" diagnostic so operators don't
* waste time chasing a generic 404.
*
* `/structure/...` endpoints remain public (no key required).
*/
readonly sdmxSubscriptionKey?: string;
/**
* Optional diagnostic hook invoked when `getWeoIndicatorsBatch()` fail-softs
* one indicator to an empty series because the IMF transport/API call failed.
* The default is no-op so callers can opt in without polluting JSON stdout.
*/
readonly onBatchIndicatorError?: (event: ImfBatchIndicatorErrorEvent) => void;
}
/** Diagnostic payload for one fail-softed `getWeoIndicatorsBatch()` item. */
export interface ImfBatchIndicatorErrorEvent {
readonly countryCode: string;
readonly indicatorId: string;
readonly error: unknown;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_DATAMAPPER_BASE_URL = 'https://www.imf.org/external/datamapper/api/v1';
const DEFAULT_SDMX_BASE_URL = 'https://api.imf.org/external/sdmx/3.0';
const DEFAULT_TIMEOUT = 15_000;
const DEFAULT_MAX_RETRIES = 2;
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; Riksdagsmonitor; +https://riksdagsmonitor.com)';
/** Default vintage. Update in April / October when the WEO re-releases. */
const DEFAULT_WEO_VINTAGE = 'WEO-2026-04';
/**
* Translate a comma-form SDMX dataflow reference (`/data/AGENCY,FLOW,VERSION/key`
* — the SDMX 2.x URN style still used by all of our docs / CLI / `weoSdmxPath`
* for human readability) into the slash form
* (`/data/dataflow/AGENCY/FLOW/VERSION/key`) that the IMF SDMX 3.0 REST gateway
* requires. The 3.0 endpoint silently 404s the comma form. Verified live
* against `api.imf.org` 2026-05-10.
*
* SDMX 3.0 is the only IMF SDMX surface this client targets; legacy SDMX
* surfaces are not configured anywhere in the repo.
*
* @internal exported for unit tests only
*/
export function normalizeSdmxPathForBase(baseURL: string, pathWithQuery: string): string {
if (!baseURL.includes('/sdmx/3.0')) {
return pathWithQuery;
}
if (pathWithQuery.includes('/data/dataflow/')) {
return pathWithQuery;
}
const re = /^(\/?data)\/([^/,?#]+),([^/,?#]+),([^/,?#]+)(\/[^?#]*)?(\?.*)?$/;
const m = re.exec(pathWithQuery);
if (!m) {
return pathWithQuery;
}
const [, dataPrefix, agency, flow, version, keyPart, query] = m;
return `${dataPrefix}/dataflow/${agency}/${flow}/${version}${keyPart ?? ''}${query ?? ''}`;
}
/** Base delay (ms) for the exponential back-off used on 429 / 5xx / network errors. */
const RETRY_BASE_DELAY_MS = 1_000;
/**
* Cap applied to a server-supplied `Retry-After` header so that a
* misbehaving origin cannot pin the client in a multi-minute sleep.
* Matches THREAT_MODEL.md TB-6a (resource-exhaustion via cooperative back-off).
*/
const RETRY_AFTER_CAP_MS = 30_000;
const NETWORK_TYPE_ERROR_PATTERNS = [
/fetch failed/i,
/failed to fetch/i,
/network/i,
/load failed/i,
] as const;
/**
* Canonical IMF indicator IDs used by Riksdagsmonitor articles.
*
* **Logical WEO surface** — these are the codes article workflows cite as
* `WEO:<code>`. Some are addressable via the simple Datamapper JSON
* transport (see {@link IMF_WEO_DATAMAPPER_AVAILABLE}); the rest only
* live in the full WEO 9.0.0 SDMX dataflow (`IMF.RES,WEO,9.0.0`) and
* require {@link ImfClient.sdmxFetch} with the
* `IMF_SDMX_SUBSCRIPTION_KEY` set. The WEO release cadence is twice a
* year (April + October) — bump {@link DEFAULT_WEO_VINTAGE} accordingly.
*/
export const IMF_WEO_INDICATORS = {
/** Real GDP growth, annual % change — headline macro indicator. */
gdpGrowth: 'NGDP_RPCH',
/** Nominal GDP, current USD. */
gdpUsd: 'NGDPD',
/** GDP per capita, current USD. */
gdpPerCapita: 'NGDPDPC',
/** Inflation, average consumer prices, annual % change. */
inflationCpi: 'PCPIPCH',
/** Unemployment rate, % of total labor force. */
unemployment: 'LUR',
/** General government gross debt, % of GDP. */
generalGovGrossDebt: 'GGXWDG_NGDP',
/** General government revenue, % of GDP. SDMX-only on Datamapper as of WEO 2026-04. */
generalGovRevenue: 'GGR_NGDP',
/** General government total expenditure, % of GDP. SDMX-only on Datamapper as of WEO 2026-04. */
generalGovExpenditure: 'GGX_NGDP',
/** General government net lending / borrowing, % of GDP. */
generalGovBalance: 'GGXCNL_NGDP',
/** Current account balance, % of GDP. */
currentAccountBalance: 'BCA_NGDPD',
/** Volume of exports of goods and services, annual % change. SDMX-only on Datamapper as of WEO 2026-04. */
exportsVolumeGrowth: 'TX_RPCH',
/** Population (millions). */
population: 'LP',
} as const;
/**
* WEO indicator codes that are reachable through the simple Datamapper
* JSON transport (`/external/datamapper/api/v1/{code}/{country}`).
*
* Verified live on 2026-05-10 against `https://www.imf.org/external/datamapper/api/v1/indicators`
* (132 indicators, 15 of which are tagged `dataset: "WEO"`). The 9
* codes below are the intersection of {@link IMF_WEO_INDICATORS} and
* the live Datamapper WEO set. Codes outside this set must be fetched
* via {@link ImfClient.sdmxFetch} against the
* `IMF.RES,WEO,9.0.0` SDMX dataflow — see {@link weoSdmxPath}.
*/
export const IMF_WEO_DATAMAPPER_AVAILABLE: ReadonlySet<string> = new Set([
'NGDP_RPCH',
'NGDPD',
'NGDPDPC',
'PCPIPCH',
'LUR',
'GGXWDG_NGDP',
'GGXCNL_NGDP',
'BCA_NGDPD',
'LP',
]);
/**
* WEO codes declared by {@link IMF_WEO_INDICATORS} that the Datamapper
* does not actually serve. The transport returns HTTP 200 with an
* **empty `values` envelope** (verified live 2026-05-10) — never a 404
* — so callers cannot rely on HTTP status alone to detect this. Route
* these through {@link ImfClient.sdmxFetch} with the WEO 9.0.0
* dataflow path produced by {@link weoSdmxPath}.
*
* `getWeoIndicator()` throws an {@link ImfWeoSdmxOnlyError} for codes in
* this set so silent zero-point returns no longer mask transport issues.
*/
export const IMF_WEO_SDMX_ONLY: ReadonlySet<string> = new Set([
'GGR_NGDP',
'GGX_NGDP',
'TX_RPCH',
'GGXONLB_NGDP',
]);
/**
* Commonly-referenced IMF Fiscal Monitor (FM) indicators. The codes
* below are the **logical** FM identifiers used in `FM:<code>` article
* citations and routed through {@link ImfClient.sdmxFetch} against the
* `IMF.FAD,FM,5.1.0` SDMX dataflow. The Datamapper FM dataset uses
* different suffix conventions (`GGXONLB_G01_GDP_PT`,
* `G_XWDG_G01_GDP_PT`, …) — see the live catalog via
* {@link ImfClient.listDatamapperIndicators}.
*/
export const IMF_FM_INDICATORS = {
/** General government gross debt, % of GDP (FM vintage — may differ slightly from WEO). */
generalGovGrossDebtFm: 'GGXWDG_NGDP',
/** General government primary balance, % of GDP. */
primaryBalance: 'GGXONLB_NGDP',
} as const;
/**
* Build the SDMX 3.0 data path for a WEO code + country, suitable for
* {@link ImfClient.sdmxFetch}. Uses the WEO 9.0.0 dataflow (the
* `IMF.RES:WEO(9.0.0)` URN browsable at
* `https://data.imf.org/en/Data-Explorer?datasetUrn=IMF.RES:WEO(9.0.0)`)
* with annual frequency.
*
* @example
* client.sdmxFetch(weoSdmxPath('SWE', 'GGR_NGDP'));
* // GET https://api.imf.org/external/sdmx/3.0/data/IMF.RES,WEO,9.0.0/A.SWE.GGR_NGDP
*/
export function weoSdmxPath(iso3: string, weoCode: string): string {
const c = encodeURIComponent(iso3.toUpperCase());
const i = encodeURIComponent(weoCode);
return `/data/IMF.RES,WEO,9.0.0/A.${c}.${i}`;
}
/**
* Thrown by {@link ImfClient.getWeoIndicator} when the requested code
* lives in {@link IMF_WEO_SDMX_ONLY} (i.e. the Datamapper transport
* returned zero points). Carries the SDMX path the caller should use
* instead so agents can recover programmatically.
*/
export class ImfWeoSdmxOnlyError extends Error {
readonly weoCode: string;
readonly countryCode: string;
readonly sdmxPath: string;
constructor(iso3: string, weoCode: string) {
const normalisedIso3 = iso3.toUpperCase();
const sdmxPath = weoSdmxPath(normalisedIso3, weoCode);
super(
`IMF WEO indicator '${weoCode}' is not exposed by the Datamapper for '${normalisedIso3}'. ` +
`Use sdmxFetch('${sdmxPath}') with IMF_SDMX_SUBSCRIPTION_KEY set, or the ` +
`'imf-fetch sdmx --path ${sdmxPath} --indicator ${weoCode} --country ${normalisedIso3}' CLI.`,
);
this.name = 'ImfWeoSdmxOnlyError';
this.weoCode = weoCode;
this.countryCode = normalisedIso3;
this.sdmxPath = sdmxPath;
}
}
// ---------------------------------------------------------------------------
// Raw Datamapper response shape
// ---------------------------------------------------------------------------
/** Shape of the IMF Datamapper JSON response (partial). */
export interface DatamapperResponse {
values?: {
[indicatorId: string]:
| {
[countryCode: string]:
| {
[year: string]: number | string | null | undefined;
}
| undefined;
}
| undefined;
};
}
/** Defensive parser input: Datamapper can return empty or partial envelopes. */
export type DatamapperEnvelope = Partial<DatamapperResponse> | null | undefined;
class ImfHttpError extends Error {
readonly status: number;
readonly retryable: boolean;
readonly retryAfterHeader?: string | null;
constructor(response: Response, requestUrl?: string, sentSubscriptionKey = false) {
const url = response.url || requestUrl || '';
const baseMessage = `IMF API error: ${response.status} ${response.statusText} for ${url}`;
const isAuthFailure = response.status === 401 || response.status === 403;
const isMaskedAuthFailure = response.status === 404 && !sentSubscriptionKey;
const isSdmxHost = url.includes('://api.imf.org/external/sdmx/') || url === '';
const message =
(isAuthFailure || isMaskedAuthFailure) && isSdmxHost
? `${baseMessage} — IMF SDMX subscription key missing or invalid (set IMF_SDMX_SUBSCRIPTION_KEY)`
: baseMessage;
super(message);
this.name = 'ImfHttpError';
this.status = response.status;
this.retryable = response.status === 429 || response.status >= 500;
this.retryAfterHeader = response.headers.get('retry-after');
}
}
// ---------------------------------------------------------------------------
// ImfClient class
// ---------------------------------------------------------------------------
/**
* HTTP client for IMF public data APIs.
*
* Primary surface:
* - `getWeoIndicator(iso3, weoCode, years?)` — fetch time series for a
* country from the WEO Datamapper
* - `compareCountriesWeo(codes, weoCode)` — latest value across a peer
* set, ideal for Nordic comparisons
* - `getLatestWeoIndicator(iso3, weoCode)` — most recent data point
*
* The SDMX 3.0 path is exposed via `sdmxFetch()` for advanced use
* (IFS / BOP / FM / GFS / DOTS / MFS / FSIC / PCPS). Agentic article
* workflows invoke this client through the `scripts/imf-fetch.ts` CLI
* via the `bash` tool (commands: `weo`, `compare`, `sdmx`,
* `list-indicators`).
*/
export class ImfClient {
readonly datamapperBaseURL: string;
readonly sdmxBaseURL: string;
readonly timeout: number;
readonly maxRetries: number;
readonly weoVintage: string;
readonly userAgent: string;
/**
* Resolved IMF SDMX subscription key. Empty string when neither the
* constructor option nor the `IMF_SDMX_SUBSCRIPTION_KEY` env var is set —
* SDMX requests still go out so probes can detect "no key" vs "outage".
*/
readonly sdmxSubscriptionKey: string;
private readonly onBatchIndicatorError?: (event: ImfBatchIndicatorErrorEvent) => void;
constructor(config: ImfClientConfig = {}) {
this.datamapperBaseURL = config.datamapperBaseURL ?? DEFAULT_DATAMAPPER_BASE_URL;
this.sdmxBaseURL = config.sdmxBaseURL ?? DEFAULT_SDMX_BASE_URL;
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
this.userAgent = config.userAgent ?? DEFAULT_USER_AGENT;
this.weoVintage = config.weoVintage ?? DEFAULT_WEO_VINTAGE;
this.sdmxSubscriptionKey =
config.sdmxSubscriptionKey ?? process.env.IMF_SDMX_SUBSCRIPTION_KEY ?? '';
this.onBatchIndicatorError = config.onBatchIndicatorError;
}
/**
* Fetch a WEO time series for one country.
*
* The Datamapper returns all years the IMF has for that indicator /
* country, mixing history and projections. Projection years are
* determined relative to the current calendar year: any year greater
* than the current year is flagged `projection: true`.
*
* @param iso3 ISO-3 alpha-3 country code (Datamapper native format)
* @param weoCode WEO indicator code (see `IMF_WEO_INDICATORS`)
* @param years How many most-recent years to return (default 10)
*/
async getWeoIndicator(
iso3: string,
weoCode: string,
years = 10,
): Promise<ImfDataPoint[]> {
if (years < 1 || !Number.isInteger(years)) {
throw new Error(`getWeoIndicator: 'years' must be a positive integer, got ${years}`);
}
const code = toDatamapperCode(iso3);
const url = `${this.datamapperBaseURL}/${encodeURIComponent(weoCode)}/${encodeURIComponent(code)}`;
const raw = (await this.fetchWithRetry(url)) as DatamapperResponse;
const points = parseDatamapperValues(raw, weoCode, code, this.weoVintage);
if (points.length === 0 && IMF_WEO_SDMX_ONLY.has(weoCode)) {
throw new ImfWeoSdmxOnlyError(iso3, weoCode);
}
return points.slice(0, years);
}
/**
* Fetch the IMF Datamapper indicator catalog
* (`https://www.imf.org/external/datamapper/api/v1/indicators`).
*
* Returned as a `Map<code, IndicatorMeta>` — 132 entries as of WEO
* 2026-04, grouped by `dataset` (`WEO`, `FM`, `FPP`, `IFS`, `BOP`,
* `DOTS`, `GFS_COFOG`, `MFS_IR`, `PCPS`, `ER`, `AFRREO`, `APDREO`,
* `WHDREO`, `EUREO`, `MCDREO`, `CL`, `CF`, `GD`, `GDD`, `SPRLU`,
* `DEBT`, `ARA`, `AIPI`, `FR_FC`).
*
* Use this to discover any of the 132 Datamapper-addressable
* indicators at runtime — the WEO subset is small (~15 codes) and
* covers the headline projections; broader queries (full WEO 9.0.0,
* IFS, BOP, GFS_COFOG, …) require {@link sdmxFetch}.
*/
async listDatamapperIndicators(): Promise<Map<string, ImfDatamapperIndicatorMeta>> {
const url = `${this.datamapperBaseURL}/indicators`;
const raw = (await this.fetchWithRetry(url)) as DatamapperIndicatorsResponse;
return parseDatamapperIndicators(raw);
}
/**
* Fetch several WEO indicators for the **same** country in sequence.
* Sequential (not parallel) to respect the IMF rate limit of
* ~10 req / 5 s. Failures on individual indicators map to an empty
* array for that indicator so a single flaky series does not poison
* the whole batch — matches the fail-soft posture of
* {@link compareCountriesWeo}.
*
* Useful for article dashboards that need a full macro+fiscal panel
* for Sweden (GDP growth, inflation, unemployment, debt, balance).
*
* @param iso3 ISO-3 alpha-3 country code
* @param weoCodes WEO indicator codes (e.g. ['NGDP_RPCH', 'PCPIPCH', 'LUR'])
* @param years How many most-recent years per indicator (default 10)
*/
async getWeoIndicatorsBatch(
iso3: string,
weoCodes: readonly string[],
years = 10,
): Promise<Map<string, readonly ImfDataPoint[]>> {
if (years < 1 || !Number.isInteger(years)) {
throw new Error(`getWeoIndicatorsBatch: 'years' must be a positive integer, got ${years}`);
}
const out = new Map<string, readonly ImfDataPoint[]>();
for (const weoCode of weoCodes) {
try {
const series = await this.getWeoIndicator(iso3, weoCode, years);
out.set(weoCode, series);
} catch (error) {
if (isTransientFetchError(error) || (error instanceof ImfHttpError && error.retryable)) {
this.onBatchIndicatorError?.({ countryCode: iso3, indicatorId: weoCode, error });
out.set(weoCode, []);
continue;
}
throw error;
}
}
return out;
}
/**
* Convenience: fetch the latest available data point for one country.
* Returns the most recent historical (non-projection) value when
* available, otherwise the most recent projection.
*/
async getLatestWeoIndicator(
iso3: string,
weoCode: string,
): Promise<ImfDataPoint | null> {
const series = await this.getWeoIndicator(iso3, weoCode, 15);
if (series.length === 0) return null;
const history = series.filter((p) => !p.projection);
return history[0] ?? series[0];
}
/**
* Compare an indicator across multiple countries. Fetches sequentially
* to respect IMF rate limits. Unknown / failed countries map to `null`.
*
* @param iso3Codes ISO-3 country codes (e.g. ['SWE', 'DNK', 'NOR'])
* @param weoCode WEO indicator code
*/
async compareCountriesWeo(
iso3Codes: readonly string[],
weoCode: string,
): Promise<Map<string, ImfDataPoint | null>> {
const out = new Map<string, ImfDataPoint | null>();
for (const code of iso3Codes) {
try {
const latest = await this.getLatestWeoIndicator(code, weoCode);
out.set(code, latest);
} catch {
out.set(code, null);
}
}
return out;
}
/**
* Low-level SDMX 3.0 passthrough. Returns the raw JSON from the IMF
* SDMX endpoint. Consumers are responsible for interpreting the SDMX
* envelope.
*
* Authentication: when {@link sdmxSubscriptionKey} is set (constructor
* option or `IMF_SDMX_SUBSCRIPTION_KEY` env var) the request includes
* the `Ocp-Apim-Subscription-Key` header — required by every SDMX 3.0
* `/data/...` endpoint as of 2026-05. SDMX 3.0 is the only IMF SDMX
* surface this client targets.
*
* @param pathWithQuery URL path starting with `/data/...` or `/structure/...`,
* optionally followed by a `?...` query string.
* @returns The parsed JSON envelope from the IMF SDMX 3.0 endpoint.
*/
async sdmxFetch(pathWithQuery: string): Promise<unknown> {
const normalized = normalizeSdmxPathForBase(this.sdmxBaseURL, pathWithQuery);
const separator = normalized.startsWith('/') ? '' : '/';
const url = `${this.sdmxBaseURL}${separator}${normalized}`;
const headers: Record<string, string> = {
Accept: 'application/vnd.sdmx.data+json;version=2.0.0',
};
if (this.sdmxSubscriptionKey) {
headers['Ocp-Apim-Subscription-Key'] = this.sdmxSubscriptionKey;
}
return this.fetchWithRetry(url, 0, headers);
}
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
private async fetchWithRetry(
url: string,
attempt = 0,
extraHeaders: Record<string, string> = {},
): Promise<unknown> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': this.userAgent, ...extraHeaders },
});
if (!response.ok) {
const sentSubscriptionKey = 'Ocp-Apim-Subscription-Key' in extraHeaders;
throw new ImfHttpError(response, url, sentSubscriptionKey);
}
return await response.json();
} catch (error) {
const retryAfterHeader = error instanceof ImfHttpError ? error.retryAfterHeader : undefined;
if (attempt < this.maxRetries && isRetryableError(error)) {
const delay = calculateRetryDelay(attempt, retryAfterHeader);
clearTimeout(timeoutId);
await new Promise((resolve) => setTimeout(resolve, delay));
return this.fetchWithRetry(url, attempt + 1, extraHeaders);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
}
// ---------------------------------------------------------------------------
// Pure helpers (exported for testability)
// ---------------------------------------------------------------------------
/**
* Compute the retry delay (milliseconds) for a given attempt number.
*
* Strategy:
* - Base schedule is exponential: 1 s → 2 s → 4 s (attempt 0/1/2).
* - When the server supplies a `Retry-After` header (delta-seconds),
* honour it, capped at {@link RETRY_AFTER_CAP_MS} to avoid pathological
* multi-minute sleeps from a misbehaving origin.
* - Invalid / non-positive `Retry-After` values fall back to the
* exponential schedule.
*
* Exported to keep the retry math verifiable without spinning up an HTTP stub.
*/
export function calculateRetryDelay(
attempt: number,
retryAfterHeader?: string | null,
): number {
const exponential = RETRY_BASE_DELAY_MS * 2 ** Math.max(0, attempt);
if (!retryAfterHeader) return exponential;
const retryAfterSec = Number.parseInt(retryAfterHeader, 10);
if (!Number.isFinite(retryAfterSec) || retryAfterSec <= 0) return exponential;
return Math.min(retryAfterSec * 1_000, RETRY_AFTER_CAP_MS);
}
/**
* Parse a raw Datamapper JSON envelope into canonical {@link ImfDataPoint}
* records for one `(indicator, country)` pair.
*
* Defensive posture:
* - Missing indicator node → `[]`
* - Missing country node → `[]`
* - `null` / `undefined` / non-finite / `'n/a'` values dropped (no silent zeros)
* - Non-numeric year keys dropped
* - Output is sorted descending by year (newest first)
*
* Pure function: no I/O, no clocks except `new Date()` for projection
* detection. Exported so tests can exercise the parser directly without
* stubbing `fetch`.
*/
export function parseDatamapperValues(
raw: DatamapperEnvelope,
weoCode: string,
iso3: string,
weoVintage: string,
): ImfDataPoint[] {
const indicatorNode = raw?.values?.[weoCode];
if (!indicatorNode) return [];
const countryNode = indicatorNode[iso3];
if (!countryNode) return [];
const currentYear = new Date().getUTCFullYear();
const points: ImfDataPoint[] = [];
for (const [year, rawValue] of Object.entries(countryNode)) {
if (rawValue === null || rawValue === undefined) continue;
const numeric = typeof rawValue === 'number' ? rawValue : Number(rawValue);
if (!Number.isFinite(numeric)) continue;
const yearInt = Number.parseInt(year, 10);
if (!Number.isFinite(yearInt)) continue;
const isProjection = yearInt > currentYear;
const dp: ImfDataPoint = {
countryCode: iso3,
countryName: iso3, // Datamapper does not return the display name; callers overlay this from COUNTRY_NAMES_EN
indicatorId: weoCode,
indicatorName: weoCode,
date: year,
value: numeric,
projection: isProjection,
provider: 'imf',
...(isProjection ? { projectionVintage: weoVintage } : {}),
};
points.push(dp);
}
points.sort((a, b) => Number.parseInt(b.date, 10) - Number.parseInt(a.date, 10));
return points;
}
// ---------------------------------------------------------------------------
// Datamapper indicator catalog
// ---------------------------------------------------------------------------
/** One entry from `https://www.imf.org/external/datamapper/api/v1/indicators`. */
export interface ImfDatamapperIndicatorMeta {
/** Canonical Datamapper indicator code (used as the primary key). */
readonly code: string;
/** Human-readable label (English). */
readonly label: string;
/** Long description (English). */
readonly description: string;
/** Source / publisher (typically "IMF"). */
readonly source: string;
/** Unit of measurement (e.g. `'Percent of GDP'`, `'Annual percent change'`). */
readonly unit: string;
/** IMF dataset family (`WEO`, `FM`, `FPP`, `IFS`, `BOP`, `DOTS`, `GFS_COFOG`, …). */
readonly dataset: string;
/** ISO 8601 last-updated timestamp emitted by the catalog (when present). */
readonly lastUpdate?: string;
}
/** Raw shape of `/external/datamapper/api/v1/indicators`. */
export interface DatamapperIndicatorsResponse {
indicators?: {
[code: string]:
| {
label?: string;
description?: string;
source?: string;
unit?: string;
dataset?: string;
lastUpdate?: string;
}
| undefined;
};
}
/**
* Pure parser for the Datamapper indicator catalog. Skips entries whose
* `dataset` is missing — defensive against IMF schema drift.
*/
export function parseDatamapperIndicators(
raw: DatamapperIndicatorsResponse | null | undefined,
): Map<string, ImfDatamapperIndicatorMeta> {
const out = new Map<string, ImfDatamapperIndicatorMeta>();
const indicators = raw?.indicators;
if (!indicators) return out;
for (const [code, meta] of Object.entries(indicators)) {
if (!meta || typeof meta !== 'object') continue;
const dataset = typeof meta.dataset === 'string' ? meta.dataset : '';
if (!dataset) continue;
out.set(code, {
code,
label: typeof meta.label === 'string' ? meta.label : '',
description: typeof meta.description === 'string' ? meta.description : '',
source: typeof meta.source === 'string' ? meta.source : '',
unit: typeof meta.unit === 'string' ? meta.unit : '',
dataset,
...(typeof meta.lastUpdate === 'string' ? { lastUpdate: meta.lastUpdate } : {}),
});
}
return out;
}
function isRetryableError(error: unknown): boolean {
if (error instanceof ImfHttpError) {
return error.retryable;
}
return isTransientFetchError(error);
}
function isTransientFetchError(error: unknown): boolean {
if (error instanceof TypeError) {
return NETWORK_TYPE_ERROR_PATTERNS.some((pattern) => pattern.test(error.message));
}
if (error instanceof Error) return error.name === 'AbortError';
return false;
}
// ---------------------------------------------------------------------------
// Singleton
// ---------------------------------------------------------------------------
let defaultImfClient: ImfClient | null = null;
/** Get or create the default singleton `ImfClient`. */
export function getDefaultImfClient(): ImfClient {
if (!defaultImfClient) {
defaultImfClient = new ImfClient();
}
return defaultImfClient;
}