From 5e0e8f73c5e82d3572ef263adeedc498656fa796 Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:00:26 +0300 Subject: [PATCH] init --- .dockerignore | 10 +++ .gitignore | 3 + Dockerfile | 15 +++++ docker-compose.yml | 17 +++++ package-lock.json | 112 +++++++++++++++++++++++++++++++ package.json | 12 ++++ src/calculations.js | 84 +++++++++++++++++++++++ src/config.js | 37 +++++++++++ src/redis.js | 159 ++++++++++++++++++++++++++++++++++++++++++++ src/server.js | 151 +++++++++++++++++++++++++++++++++++++++++ src/sources.js | 74 +++++++++++++++++++++ src/vault.js | 125 ++++++++++++++++++++++++++++++++++ 12 files changed, 799 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/calculations.js create mode 100644 src/config.js create mode 100644 src/redis.js create mode 100644 src/server.js create mode 100644 src/sources.js create mode 100644 src/vault.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..95b77d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +*.log +.git +.gitignore +Dockerfile +docker-compose.yml +.env +test +*.md +.claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53a89d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c25a9ea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-slim + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY src ./src + +ENV NODE_ENV=production \ + PORT=3004 + +EXPOSE 3004 + +CMD ["node", "src/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c845a9d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + gweiparser: + build: . + image: gweiparser:latest + container_name: gweiparser + restart: unless-stopped + env_file: .env + environment: + PORT: 3004 + ports: + - "3004:3004" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3004/ping').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4824d2e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,112 @@ +{ + "name": "gwei-parser", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gwei-parser", + "version": "1.0.0", + "dependencies": { + "redis": "^5.8.2" + } + }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..84fc894 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "gwei-parser", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "redis": "^5.8.2" + } +} diff --git a/src/calculations.js b/src/calculations.js new file mode 100644 index 0000000..89f3611 --- /dev/null +++ b/src/calculations.js @@ -0,0 +1,84 @@ +const WEI_PER_GWEI = 1_000_000_000; +const SLOW_REWARD_INDEX = 0; + +export function roundUpToTenth(value) { + if (!Number.isFinite(value)) { + throw new TypeError('Expected a finite number'); + } + + return Math.ceil((value - Number.EPSILON) * 10) / 10; +} + +export function calculateCostRub({ gwei, gasLimit, ethUsd, usdRub }) { + const ethCost = (gwei * gasLimit) / WEI_PER_GWEI; + const rubCost = ethCost * ethUsd * usdRub; + + return roundUpToTenth(rubCost); +} + +export function createGasPayload({ + feeHistory, + gasLimit, + ethUsd, + usdRub, + now = new Date(), +}) { + const gasModes = parseFeeHistory(feeHistory); + + return { + network: 'ethereum-mainnet', + gasLimit, + updatedAt: now.toISOString(), + status: 'fresh', + error: null, + rates: { ethUsd, usdRub }, + modes: Object.fromEntries( + Object.entries(gasModes).map(([mode, gwei]) => [ + mode, + { + gwei, + costRub: calculateCostRub({ gwei, gasLimit, ethUsd, usdRub }), + }, + ]), + ), + }; +} + +export function parseFeeHistory(feeHistory) { + const baseFees = feeHistory?.baseFeePerGas; + const rewards = feeHistory?.reward; + + if (!Array.isArray(baseFees) || baseFees.length === 0) { + throw new Error('feeHistory.baseFeePerGas is missing'); + } + + if (!Array.isArray(rewards) || rewards.length === 0) { + throw new Error('feeHistory.reward is missing'); + } + + const nextBaseFeeGwei = weiHexToGwei(baseFees.at(-1)); + const latestRewards = rewards.at(-1); + + if (!Array.isArray(latestRewards) || latestRewards.length < 1) { + throw new Error('feeHistory.reward must include at least the slow percentile'); + } + + const slowPriorityFeeGwei = weiHexToGwei(latestRewards[SLOW_REWARD_INDEX]); + const slow = roundUpToTenth(nextBaseFeeGwei + slowPriorityFeeGwei); + const fast = roundUpToTenth(slow * 2); + const normal = roundUpToTenth((slow + fast) / 2); + + return { + slow, + normal, + fast, + }; +} + +function weiHexToGwei(hexValue) { + if (typeof hexValue !== 'string' || !hexValue.startsWith('0x')) { + throw new Error(`Invalid wei hex value: ${hexValue}`); + } + + return Number(BigInt(hexValue)) / WEI_PER_GWEI; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..66d4378 --- /dev/null +++ b/src/config.js @@ -0,0 +1,37 @@ +try { + process.loadEnvFile?.('.env'); +} catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } +} + +function numberFromEnv(name, fallback) { + const rawValue = process.env[name]; + if (rawValue === undefined || rawValue === '') { + return fallback; + } + + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive number`); + } + + return parsed; +} + +export const config = { + port: numberFromEnv('PORT', 3000), + rpcUrl: process.env.ETH_RPC_URL || 'https://ethereum-rpc.publicnode.com', + redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', + redisKeyPrefix: process.env.REDIS_KEY_PREFIX || 'gwei', + vaultAddr: process.env.VAULT_ADDR || '', + vaultMountPoint: process.env.VAULT_MOUNT_POINT || '', + vaultRoleId: process.env.VAULT_ROLE_ID || '', + vaultSecretId: process.env.VAULT_SECRET_ID || '', + vaultKeydbSecretPath: process.env.VAULT_KEYDB_SECRET_PATH || 'keydb', + gasLimit: numberFromEnv('GAS_LIMIT', 65000), + gasRefreshMs: numberFromEnv('GAS_REFRESH_MS', 30000), + ratesRefreshMs: numberFromEnv('RATES_REFRESH_MS', 60000), + requestTimeoutMs: numberFromEnv('REQUEST_TIMEOUT_MS', 10000), +}; diff --git a/src/redis.js b/src/redis.js new file mode 100644 index 0000000..29a4457 --- /dev/null +++ b/src/redis.js @@ -0,0 +1,159 @@ +import { createClient } from 'redis'; + +const MODE_NAMES = ['slow', 'normal', 'fast']; + +export function buildRedisUrl({ + host, + port, + password, + database, + scheme = 'redis', +}) { + if (!host) { + throw new Error('KeyDB host is required'); + } + + const url = new URL(`${scheme}://localhost`); + url.hostname = host; + + if (port !== undefined && port !== null && port !== '') { + url.port = String(port); + } + + if (password) { + url.password = password; + } + + if (database !== undefined && database !== null && database !== '') { + url.pathname = `/${database}`; + } + + return url.toString(); +} + +export function sanitizeRedisUrl(redisUrl) { + const url = new URL(redisUrl); + + if (url.password) { + url.password = '***'; + } + + return url.toString(); +} + +export function buildRedisModeHash(payload, { keyPrefix = 'gwei' } = {}) { + if (!payload?.modes) { + throw new Error('Payload does not contain modes'); + } + + const fields = {}; + + for (const mode of MODE_NAMES) { + const gwei = payload.modes?.[mode]?.gwei; + const costRub = payload.modes?.[mode]?.costRub; + + if (!Number.isFinite(gwei)) { + throw new Error(`Payload does not contain a numeric ${mode} gwei value`); + } + + if (!Number.isFinite(costRub)) { + throw new Error(`Payload does not contain a numeric ${mode} costRub value`); + } + + fields[mode] = gwei.toFixed(1); + fields[`${mode}_rub`] = costRub.toFixed(1); + } + + const ethUsd = payload.rates?.ethUsd; + const usdRub = payload.rates?.usdRub; + + if (Number.isFinite(ethUsd)) { + fields.eth_usd = ethUsd.toFixed(2); + } + + if (Number.isFinite(usdRub)) { + fields.usd_rub = usdRub.toFixed(4); + } + + if (Number.isFinite(payload.gasLimit)) { + fields.gas_limit = String(payload.gasLimit); + } + + fields.ts = payload.updatedAt || new Date().toISOString(); + + return { + key: `${keyPrefix}:eth:last`, + fields, + }; +} + +export class RedisModeWriter { + constructor({ url, keyPrefix = 'gwei', logger = console }) { + this.url = url; + this.keyPrefix = keyPrefix; + this.logger = logger; + this.client = null; + this.connecting = null; + } + + async writeModes(payload) { + await this.connect(); + const { key, fields } = buildRedisModeHash(payload, { + keyPrefix: this.keyPrefix, + }); + + await this.client.hSet(key, fields); + this.logger.log(`Redis updated hash ${key} (${Object.keys(fields).length} fields)`); + } + + async close() { + if (!this.client) { + return; + } + + const client = this.client; + this.client = null; + this.connecting = null; + await client.quit().catch(() => client.disconnect()); + } + + async connect() { + if (this.client?.isReady) { + return this.client; + } + + if (this.connecting) { + return this.connecting; + } + + const client = createClient({ url: this.url }); + client.on('error', (error) => { + this.logger.warn(`Redis connection error: ${describeError(error)}`); + }); + + this.connecting = client.connect().then(() => { + this.client = client; + this.connecting = null; + this.logger.log(`Redis writer connected to ${sanitizeRedisUrl(this.url)}`); + return client; + }).catch((error) => { + this.connecting = null; + client.disconnect(); + throw error; + }); + + return this.connecting; + } +} + +function describeError(error) { + if (error instanceof Error && error.message) { + return error.message; + } + + if (error?.code) { + return error.code; + } + + return String(error || 'unknown error'); +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..19d0137 --- /dev/null +++ b/src/server.js @@ -0,0 +1,151 @@ +import http from 'node:http'; + +import { createGasPayload } from './calculations.js'; +import { config } from './config.js'; +import { RedisModeWriter, buildRedisUrl } from './redis.js'; +import { fetchFeeHistory, fetchRates } from './sources.js'; +import { fetchKeyDbConfigFromVault } from './vault.js'; + +const state = { + rates: null, + lastRatesRefreshAt: 0, +}; + +let redisWriter = null; +let tickTimer = null; + +const server = http.createServer((request, response) => { + if (request.method !== 'GET') { + sendText(response, 405, 'Method Not Allowed'); + return; + } + + const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`); + + if (url.pathname === '/ping') { + sendJson(response, 200, { status: 'ok' }); + return; + } + + sendText(response, 404, 'Not Found'); +}); + +start().catch((error) => { + console.error(`Startup failed: ${describeError(error)}`); + process.exit(1); +}); + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +async function start() { + redisWriter = await createRedisWriter(); + await redisWriter.connect(); + + await tick(); + tickTimer = setInterval(() => { + tick().catch((error) => { + console.error(`Gas refresh failed: ${describeError(error)}`); + }); + }, config.gasRefreshMs); + + server.listen(config.port, () => { + console.log(`Gwei parser listening on http://localhost:${config.port}`); + }); +} + +async function tick() { + try { + await ensureFreshRates(); + const feeHistory = await fetchFeeHistory({ + rpcUrl: config.rpcUrl, + timeoutMs: config.requestTimeoutMs, + }); + + const payload = createGasPayload({ + feeHistory, + gasLimit: config.gasLimit, + ethUsd: state.rates.ethUsd, + usdRub: state.rates.usdRub, + }); + + await redisWriter.writeModes(payload); + } catch (error) { + console.error(`Gas refresh failed: ${describeError(error)}`); + } +} + +async function ensureFreshRates() { + const ratesAgeMs = Date.now() - state.lastRatesRefreshAt; + if (state.rates && ratesAgeMs < config.ratesRefreshMs) { + return; + } + + state.rates = await fetchRates({ timeoutMs: config.requestTimeoutMs }); + state.lastRatesRefreshAt = Date.now(); +} + +async function createRedisWriter() { + if (config.vaultAddr && config.vaultMountPoint && config.vaultRoleId && config.vaultSecretId) { + const keydbConfig = await fetchKeyDbConfigFromVault({ + vaultAddr: config.vaultAddr, + mountPoint: config.vaultMountPoint, + roleId: config.vaultRoleId, + secretId: config.vaultSecretId, + secretPath: config.vaultKeydbSecretPath, + timeoutMs: config.requestTimeoutMs, + }); + + const url = buildRedisUrl(keydbConfig); + console.log( + `Resolved KeyDB credentials from Vault ${config.vaultMountPoint}/${config.vaultKeydbSecretPath}`, + ); + return new RedisModeWriter({ + url, + keyPrefix: config.redisKeyPrefix, + }); + } + + return new RedisModeWriter({ + url: config.redisUrl, + keyPrefix: config.redisKeyPrefix, + }); +} + +async function shutdown() { + console.log('Shutting down...'); + if (tickTimer) { + clearInterval(tickTimer); + tickTimer = null; + } + server.close(); + await redisWriter?.close(); + process.exit(0); +} + +function sendJson(response, statusCode, data) { + response.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(JSON.stringify(data)); +} + +function sendText(response, statusCode, body) { + response.writeHead(statusCode, { + 'content-type': 'text/plain; charset=utf-8', + }); + response.end(body); +} + +function describeError(error) { + if (error instanceof Error && error.message) { + return error.message; + } + + if (error?.code) { + return error.code; + } + + return String(error || 'unknown error'); +} diff --git a/src/sources.js b/src/sources.js new file mode 100644 index 0000000..9925c00 --- /dev/null +++ b/src/sources.js @@ -0,0 +1,74 @@ +const FEE_HISTORY_BLOCK_COUNT = '0x5'; +const FEE_HISTORY_REWARD_PERCENTILES = [10, 50, 90]; +const ETH_USD_SPOT_URL = 'https://api.coinbase.com/v2/prices/ETH-USD/spot'; +const USD_RATES_URL = 'https://api.coinbase.com/v2/exchange-rates?currency=USD'; + +export async function fetchFeeHistory({ rpcUrl, timeoutMs }) { + const response = await fetchJson(rpcUrl, { + timeoutMs, + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_feeHistory', + params: [FEE_HISTORY_BLOCK_COUNT, 'latest', FEE_HISTORY_REWARD_PERCENTILES], + }), + }); + + if (response.error) { + throw new Error(`Ethereum RPC error: ${response.error.message || response.error.code}`); + } + + if (!response.result) { + throw new Error('Ethereum RPC response did not include result'); + } + + return response.result; +} + +export async function fetchRates({ timeoutMs }) { + const [ethUsdResponse, usdRatesResponse] = await Promise.all([ + fetchJson(ETH_USD_SPOT_URL, { timeoutMs }), + fetchJson(USD_RATES_URL, { timeoutMs }), + ]); + + const ethUsd = Number(ethUsdResponse?.data?.amount); + const usdRub = Number(usdRatesResponse?.data?.rates?.RUB); + + if (!Number.isFinite(ethUsd) || ethUsd <= 0) { + throw new Error('Coinbase ETH/USD response is invalid'); + } + + if (!Number.isFinite(usdRub) || usdRub <= 0) { + throw new Error('Coinbase USD/RUB response is invalid'); + } + + return { ethUsd, usdRub }; +} + +async function fetchJson(url, { timeoutMs, ...options } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + accept: 'application/json', + ...(options.headers || {}), + }, + }); + + if (!response.ok) { + throw new Error(`${url} returned HTTP ${response.status}`); + } + + return await response.json(); + } finally { + clearTimeout(timeout); + } +} diff --git a/src/vault.js b/src/vault.js new file mode 100644 index 0000000..ebcc8d9 --- /dev/null +++ b/src/vault.js @@ -0,0 +1,125 @@ +export async function fetchKeyDbConfigFromVault({ + vaultAddr, + mountPoint, + roleId, + secretId, + secretPath = 'keydb', + timeoutMs = 10000, +}) { + const token = await loginWithAppRole({ + vaultAddr, + roleId, + secretId, + timeoutMs, + }); + + const secret = await fetchKvV2Secret({ + vaultAddr, + mountPoint, + secretPath, + token, + timeoutMs, + }); + + return normalizeKeyDbSecret(secret); +} + +async function loginWithAppRole({ vaultAddr, roleId, secretId, timeoutMs }) { + const response = await fetchJson(`${vaultAddr}/v1/auth/approle/login`, { + timeoutMs, + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + role_id: roleId, + secret_id: secretId, + }), + }); + + const token = response?.auth?.client_token; + if (!token) { + throw new Error('Vault AppRole login did not return a client token'); + } + + return token; +} + +async function fetchKvV2Secret({ + vaultAddr, + mountPoint, + secretPath, + token, + timeoutMs, +}) { + const response = await fetchJson( + `${vaultAddr}/v1/${mountPoint}/data/${secretPath}`, + { + timeoutMs, + headers: { + 'X-Vault-Token': token, + }, + }, + ); + + const secret = response?.data?.data; + if (!secret) { + throw new Error(`Vault secret ${mountPoint}/${secretPath} does not contain data`); + } + + return secret; +} + +function normalizeKeyDbSecret(secret) { + const host = secret.host; + const password = secret.password; + const port = Number(secret.port); + const database = Number(secret.database); + + if (!host) { + throw new Error('Vault KeyDB secret is missing host'); + } + + if (!password) { + throw new Error('Vault KeyDB secret is missing password'); + } + + if (!Number.isFinite(port) || port <= 0) { + throw new Error('Vault KeyDB secret has an invalid port'); + } + + if (!Number.isFinite(database) || database < 0) { + throw new Error('Vault KeyDB secret has an invalid database'); + } + + return { + host, + password, + port, + database, + }; +} + +async function fetchJson(url, { timeoutMs, ...options } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + accept: 'application/json', + ...(options.headers || {}), + }, + }); + + if (!response.ok) { + throw new Error(`Vault request failed with HTTP ${response.status}`); + } + + return await response.json(); + } finally { + clearTimeout(timeout); + } +}