diff --git a/src/data/traffic.test.mjs b/src/data/traffic.test.mjs index 23924448a..0e8ada30a 100644 --- a/src/data/traffic.test.mjs +++ b/src/data/traffic.test.mjs @@ -30,6 +30,28 @@ test('a superseded flow fetch is not an outage', () => { assert.equal(deriveTrafficFlowError(undefined), null); }); +test('hosted account limits and service failures name their own reason', () => { + const failure = (status, code) => + deriveTrafficFlowError( + Object.assign(new Error(`TomTom flow unavailable (HTTP ${status})`), { + status, + code, + }), + ); + assert.equal( + failure(429, 'USAGE_LIMIT_REACHED'), + 'Monthly traffic allowance used', + ); + assert.equal(failure(429, 'RATE_LIMITED'), 'Traffic rate limit reached'); + assert.equal(failure(429, 'budget'), 'TomTom daily budget reached'); + assert.equal(failure(503, 'no_key'), 'TomTom key unavailable'); + assert.equal(failure(503, null), 'TomTom key unavailable'); + assert.equal( + failure(503, 'USAGE_UNAVAILABLE'), + 'Traffic service unavailable', + ); +}); + test('flow failures map onto short, specific reasons', () => { const reason = (message) => deriveTrafficFlowError(new Error(message)); assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable'); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index b491c3c93..8a2b81e20 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1749,6 +1749,9 @@ "Hybrid": "هجين", "TomTom key unavailable": "مفتاح TomTom غير متاح", "TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom", + "Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية", + "Traffic rate limit reached": "تم بلوغ حد طلبات حركة المرور", + "Traffic service unavailable": "خدمة حركة المرور غير متاحة", "TomTom upstream unreachable": "تعذّر الوصول إلى خادم TomTom", "TomTom flow unavailable": "تدفق TomTom غير متاح", "SIMULATED — {reason}": "محاكى — {reason}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d01ff292f..837a87471 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1667,6 +1667,9 @@ "Hybrid": "Hybrid", "TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar", "TomTom daily budget reached": "TomTom-Tagesbudget erreicht", + "Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht", + "Traffic rate limit reached": "Verkehrs-Anfragelimit erreicht", + "Traffic service unavailable": "Verkehrsdienst nicht verfügbar", "TomTom upstream unreachable": "TomTom-Upstream nicht erreichbar", "TomTom flow unavailable": "TomTom-Verkehrsfluss nicht verfügbar", "SIMULATED — {reason}": "SIMULIERT – {reason}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index aa403f743..57aa09eb7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2274,6 +2274,9 @@ "SIMULATED — {reason}": "SIMULADO — {reason}", "TomTom key unavailable": "Clave de TomTom no disponible", "TomTom daily budget reached": "Se alcanzó el límite diario de TomTom", + "Monthly traffic allowance used": "Cupo mensual de tráfico agotado", + "Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado", + "Traffic service unavailable": "Servicio de tráfico no disponible", "TomTom upstream unreachable": "Servidor de TomTom inaccesible", "TomTom flow unavailable": "Flujo de TomTom no disponible", "API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b3ae72189..bfd2a5975 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1105,6 +1105,9 @@ "Hybrid": "Hybride", "TomTom key unavailable": "Clé TomTom indisponible", "TomTom daily budget reached": "Quota quotidien TomTom atteint", + "Monthly traffic allowance used": "Quota mensuel de trafic épuisé", + "Traffic rate limit reached": "Limite de requêtes de trafic atteinte", + "Traffic service unavailable": "Service de trafic indisponible", "TomTom upstream unreachable": "Serveur TomTom injoignable", "TomTom flow unavailable": "Flux TomTom indisponible", "SIMULATED — {reason}": "SIMULÉ — {reason}", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4c69e1d4c..74c0d01aa 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1105,6 +1105,9 @@ "Hybrid": "Ibrido", "TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito", + "Monthly traffic allowance used": "Quota mensile del traffico esaurita", + "Traffic rate limit reached": "Limite di richieste del traffico raggiunto", + "Traffic service unavailable": "Servizio traffico non disponibile", "TomTom upstream unreachable": "Servizio TomTom irraggiungibile", "TomTom flow unavailable": "Flusso TomTom non disponibile", "SIMULATED — {reason}": "SIMULATO: {reason}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9a053f869..932d851e2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1073,6 +1073,9 @@ "Hybrid": "ハイブリッド", "TomTom key unavailable": "TomTomキーを利用できません", "TomTom daily budget reached": "TomTomの1日の上限に達しました", + "Monthly traffic allowance used": "今月の交通情報の上限に達しました", + "Traffic rate limit reached": "交通情報のリクエスト上限に達しました", + "Traffic service unavailable": "交通情報サービスを利用できません", "TomTom upstream unreachable": "TomTomのサービスに接続できません", "TomTom flow unavailable": "TomTomの交通流を利用できません", "SIMULATED — {reason}": "シミュレーション:{reason}", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 79c507759..e71f144d5 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1103,6 +1103,9 @@ "Hybrid": "Híbrido", "TomTom key unavailable": "Chave do TomTom indisponível", "TomTom daily budget reached": "Limite diário do TomTom atingido", + "Monthly traffic allowance used": "Cota mensal de trânsito esgotada", + "Traffic rate limit reached": "Limite de solicitações de trânsito atingido", + "Traffic service unavailable": "Serviço de trânsito indisponível", "TomTom upstream unreachable": "Servidor do TomTom inacessível", "TomTom flow unavailable": "Fluxo do TomTom indisponível", "SIMULATED — {reason}": "SIMULADO — {reason}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 11fddb3e9..6573ad11b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2313,6 +2313,9 @@ "SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}", "TomTom key unavailable": "Ключ TomTom недоступен", "TomTom daily budget reached": "Исчерпан дневной лимит TomTom", + "Monthly traffic allowance used": "Месячный лимит данных о трафике исчерпан", + "Traffic rate limit reached": "Достигнут лимит запросов данных о трафике", + "Traffic service unavailable": "Сервис данных о трафике недоступен", "TomTom upstream unreachable": "Сервер TomTom не отвечает", "TomTom flow unavailable": "Поток TomTom недоступен", "API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index c0fab8533..18ea8dc4f 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1120,6 +1120,9 @@ "Hybrid": "Гібрид", "TomTom key unavailable": "Ключ TomTom недоступний", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom", + "Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано", + "Traffic rate limit reached": "Досягнуто ліміту запитів даних про трафік", + "Traffic service unavailable": "Сервіс даних про трафік недоступний", "TomTom upstream unreachable": "Сервер TomTom не відповідає", "TomTom flow unavailable": "Потік TomTom недоступний", "SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8687af790..8d09b74a3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1049,6 +1049,9 @@ "Hybrid": "混合", "TomTom key unavailable": "TomTom 密钥不可用", "TomTom daily budget reached": "已达 TomTom 每日配额", + "Monthly traffic allowance used": "本月交通流量额度已用完", + "Traffic rate limit reached": "已达到交通数据请求频率上限", + "Traffic service unavailable": "交通服务不可用", "TomTom upstream unreachable": "TomTom 上游服务无法连接", "TomTom flow unavailable": "TomTom 路况不可用", "SIMULATED — {reason}": "模拟——{reason}", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index a95e71dd5..30801f0f9 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1044,6 +1044,9 @@ "Hybrid": "混合", "TomTom key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", + "Monthly traffic allowance used": "本月交通流量額度已用完", + "Traffic rate limit reached": "已達交通資料請求頻率上限", + "Traffic service unavailable": "交通服務無法使用", "TomTom upstream unreachable": "TomTom 上游服務無法連接", "TomTom flow unavailable": "TomTom 路況不可用", "SIMULATED — {reason}": "模擬——{reason}", diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 2064c10ce..caf8aede2 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -38,7 +38,15 @@ export function createFlow({ state: layerState, services, parts, source }) { const status = Number.isFinite(error.status) ? error.status : Number(message.match(/HTTP (\d{3})/)?.[1]); + const code = typeof error.code === 'string' ? error.code : null; // English in state; trafficFeedPresentation translates it for the panel. + // A hosted deployment limits each account (code from the server); the + // local proxy has one daily budget (`budget`) and answers `no_key`. + if (code === 'USAGE_LIMIT_REACHED') + return msg('Monthly traffic allowance used'); + if (code === 'RATE_LIMITED') return msg('Traffic rate limit reached'); + if (status === 503 && code && code !== 'no_key') + return msg('Traffic service unavailable'); if (status === 503) return msg('TomTom key unavailable'); if (status === 429) return msg('TomTom daily budget reached'); if (status === 502 || status === 504) diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 12e746e43..e5402cf12 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -11,6 +11,26 @@ const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`; * (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom * fails), and when the proxy fetched it. */ +/** + * The machine-readable reason a failed tile carries: `code` (hosted usage, + * e.g. USAGE_LIMIT_REACHED or RATE_LIMITED) or the local proxy's `error` + * (`no_key`, `budget`). Only short identifiers are kept; prose is ignored. + */ +async function failureCode(response) { + if (!/json/i.test(response.headers?.get?.('content-type') || '')) return null; + try { + const text = await response.text(); + if (text.length > 4096) return null; + const body = JSON.parse(text); + const code = body?.code ?? body?.error; + return typeof code === 'string' && /^[A-Za-z_]{1,40}$/.test(code) + ? code + : null; + } catch { + return null; + } +} + function tileFreshness(response) { const status = response.headers?.get?.('x-tomtom-cache') || ''; const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at')); @@ -60,7 +80,16 @@ export function createFlowTileSource({ }, fetchImpl: async (url, init) => { const response = await fetchImpl(url, init); - if (response.ok && !init.signal.aborted) { + if (!response.ok) { + const code = await failureCode(response); + await response.body?.cancel().catch(() => {}); + throw Object.assign( + // Classified for display by deriveTrafficFlowError. + new Error(`TomTom flow unavailable (HTTP ${response.status})`), + { status: response.status, code }, + ); + } + if (!init.signal.aborted) { const path = String(url); const entry = { mark: tileFreshness(response), diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 779164b7c..e45f5ef0f 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -1050,3 +1050,29 @@ test('cancelling a flow body releases its freshness before a new request', async assert.equal(getEventListeners(requestSignal, 'abort').length, 0); assert.equal(source.getFlowSessionStats().cacheEntries, 0); }); + +test('a failed flow tile carries the server reason for its label', async () => { + for (const [status, body, code] of [ + [ + 429, + { error: 'Monthly usage limit reached', code: 'USAGE_LIMIT_REACHED' }, + 'USAGE_LIMIT_REACHED', + ], + [ + 429, + { error: 'Request rate limit reached', code: 'RATE_LIMITED' }, + 'RATE_LIMITED', + ], + [503, { error: 'no_key' }, 'no_key'], + [503, { error: 'Usage enforcement is temporarily unavailable' }, null], + ]) { + const source = createFlowTileSource({ + fetchImpl: async () => Response.json(body, { status }), + }); + await assert.rejects(source.fetchFlowForBounds(bounds), (error) => { + assert.equal(error.status, status); + assert.equal(error.code, code); + return true; + }); + } +});