diff --git a/scripts/build_and_run.sh b/scripts/build_and_run.sh old mode 100644 new mode 100755 index ea18b9b2..4278ab41 --- a/scripts/build_and_run.sh +++ b/scripts/build_and_run.sh @@ -1,7 +1,7 @@ #!/bin/bash # Navigate to the src directory -cd ../src +cd src # Build the managing.api image (now includes all workers as background services) docker build -t managing.api -f Managing.Api/Dockerfile . --no-cache diff --git a/src/Managing.Api/Managing.Api.csproj b/src/Managing.Api/Managing.Api.csproj index 3f5124ca..da7879b6 100644 --- a/src/Managing.Api/Managing.Api.csproj +++ b/src/Managing.Api/Managing.Api.csproj @@ -32,6 +32,8 @@ + + diff --git a/src/Managing.Api/appsettings.Production.json b/src/Managing.Api/appsettings.Production.json index 5e33255f..f20fa772 100644 --- a/src/Managing.Api/appsettings.Production.json +++ b/src/Managing.Api/appsettings.Production.json @@ -13,7 +13,10 @@ "AppSecret": "3STq1UyPJ5WHixArBcVBKecWtyR4QpgZ1uju4HHvvJH2RwtacJnvoyzuaiNC8Xibi4rQb3eeH2YtncKrMxCYiV3a" }, "Web3Proxy": { - "BaseUrl": "http://srv-captain--web3-proxy:4111" + "BaseUrl": "http://srv-captain--web3-proxy:4111", + "MaxRetryAttempts": 3, + "RetryDelayMs": 1000, + "TimeoutSeconds": 30 }, "Serilog": { "MinimumLevel": { diff --git a/src/Managing.Api/appsettings.Sandbox.json b/src/Managing.Api/appsettings.Sandbox.json index c3a66a73..0652c693 100644 --- a/src/Managing.Api/appsettings.Sandbox.json +++ b/src/Managing.Api/appsettings.Sandbox.json @@ -18,7 +18,10 @@ "RefundEndpoint": "/api/credits/refund" }, "Web3Proxy": { - "BaseUrl": "http://srv-captain--web3-proxy:4111" + "BaseUrl": "http://srv-captain--web3-proxy:4111", + "MaxRetryAttempts": 3, + "RetryDelayMs": 1000, + "TimeoutSeconds": 30 }, "Serilog": { "MinimumLevel": { diff --git a/src/Managing.Api/appsettings.json b/src/Managing.Api/appsettings.json index 1550a3c2..fa0d2cbd 100644 --- a/src/Managing.Api/appsettings.json +++ b/src/Managing.Api/appsettings.json @@ -21,7 +21,10 @@ "Token": "" }, "Web3Proxy": { - "BaseUrl": "http://localhost:4111" + "BaseUrl": "http://localhost:4111", + "MaxRetryAttempts": 3, + "RetryDelayMs": 1000, + "TimeoutSeconds": 30 }, "Kaigen": { "BaseUrl": "https://kaigen-back-development.up.railway.app", diff --git a/src/Managing.Docker/docker-compose.local.yml b/src/Managing.Docker/docker-compose.local.yml index f4485abe..039da6d8 100644 --- a/src/Managing.Docker/docker-compose.local.yml +++ b/src/Managing.Docker/docker-compose.local.yml @@ -45,3 +45,24 @@ services: - 8086:8086 restart: always + redis: + image: redis:8.0.3 + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - managing-network + restart: unless-stopped + environment: + - REDIS_PASSWORD=SuperSecretPassword + command: > + sh -c " + if [ -n \"$$REDIS_PASSWORD\" ]; then + redis-server --appendonly yes --requirepass $$REDIS_PASSWORD + redis-cli -a $$REDIS_PASSWORD + else + redis-server --appendonly yes + redis-cli + fi + " \ No newline at end of file diff --git a/src/Managing.Docker/docker-compose.yml b/src/Managing.Docker/docker-compose.yml index c4314f05..447a14ab 100644 --- a/src/Managing.Docker/docker-compose.yml +++ b/src/Managing.Docker/docker-compose.yml @@ -20,9 +20,17 @@ services: networks: - managing-network + redis: + image: redis:8.0.3 + volumes: + - redis_data:/data + networks: + - managing-network + volumes: influxdata: {} postgresdata: {} + redis_data: {} networks: managing-network: diff --git a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs index f9e98400..f9ddacb8 100644 --- a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs +++ b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.Json; @@ -6,7 +7,9 @@ using System.Web; using Managing.Application.Abstractions.Services; using Managing.Domain.Accounts; using Managing.Infrastructure.Evm.Models.Proxy; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Polly; using static Managing.Common.Enums; namespace Managing.Infrastructure.Evm.Services @@ -14,6 +17,9 @@ namespace Managing.Infrastructure.Evm.Services public class Web3ProxySettings { public string BaseUrl { get; set; } = "http://localhost:3000"; + public int MaxRetryAttempts { get; set; } = 3; + public int RetryDelayMs { get; set; } = 1000; + public int TimeoutSeconds { get; set; } = 30; } public class Web3ProxyService : IWeb3ProxyService @@ -21,15 +27,98 @@ namespace Managing.Infrastructure.Evm.Services private readonly HttpClient _httpClient; private readonly Web3ProxySettings _settings; private readonly JsonSerializerOptions _jsonOptions; + private readonly IAsyncPolicy _retryPolicy; + private readonly ILogger _logger; - public Web3ProxyService(IOptions options) + public Web3ProxyService(IOptions options, ILogger logger) { - _httpClient = new HttpClient(); _settings = options.Value; + _logger = logger; + + _httpClient = new HttpClient(); + _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + // Configure retry policy + _retryPolicy = Policy + .Handle() + .Or() + .Or() + .OrResult(r => !r.IsSuccessStatusCode && IsRetryableStatusCode(r.StatusCode)) + .WaitAndRetryAsync( + retryCount: _settings.MaxRetryAttempts, + sleepDurationProvider: retryAttempt => TimeSpan.FromMilliseconds( + _settings.RetryDelayMs * Math.Pow(2, retryAttempt - 1) + // Exponential backoff + new Random().Next(0, _settings.RetryDelayMs / 4) // Add jitter + ), + onRetry: (outcome, timespan, retryCount, context) => + { + var exception = outcome.Exception; + var response = outcome.Result; + var errorMessage = exception?.Message ?? $"HTTP {response?.StatusCode}"; + + _logger.LogWarning( + "Web3Proxy request failed (attempt {RetryCount}/{MaxRetries}): {Error}. Retrying in {Delay}ms", + retryCount, _settings.MaxRetryAttempts + 1, errorMessage, timespan.TotalMilliseconds); + }); + } + + private static bool IsRetryableStatusCode(HttpStatusCode statusCode) + { + return statusCode == HttpStatusCode.RequestTimeout || + statusCode == HttpStatusCode.TooManyRequests || + statusCode == HttpStatusCode.InternalServerError || + statusCode == HttpStatusCode.BadGateway || + statusCode == HttpStatusCode.ServiceUnavailable || + statusCode == HttpStatusCode.GatewayTimeout; + } + + private async Task ExecuteWithRetryAsync(Func> httpCall, string operationName) + { + try + { + var response = await _retryPolicy.ExecuteAsync(httpCall); + + if (!response.IsSuccessStatusCode) + { + await HandleErrorResponse(response); + } + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions); + return result ?? throw new Web3ProxyException($"Failed to deserialize response for {operationName}"); + } + catch (Exception ex) when (!(ex is Web3ProxyException)) + { + _logger.LogError(ex, "Operation {OperationName} failed after all retry attempts", operationName); + SentrySdk.CaptureException(ex); + throw new Web3ProxyException($"Failed to execute {operationName}: {ex.Message}"); + } + } + + private async Task ExecuteWithRetryAsync(Func> httpCall, string operationName, string idempotencyKey) + { + try + { + var response = await _retryPolicy.ExecuteAsync(httpCall); + + if (!response.IsSuccessStatusCode) + { + await HandleErrorResponse(response); + } + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions); + return result ?? throw new Web3ProxyException($"Failed to deserialize response for {operationName}"); + } + catch (Exception ex) when (!(ex is Web3ProxyException)) + { + _logger.LogError(ex, "Operation {OperationName} failed after all retry attempts (IdempotencyKey: {IdempotencyKey})", operationName, idempotencyKey); + SentrySdk.CaptureException(ex); + throw new Web3ProxyException($"Failed to execute {operationName}: {ex.Message}"); + } } public async Task CallPrivyServiceAsync(string endpoint, object payload) @@ -40,26 +129,22 @@ namespace Managing.Infrastructure.Evm.Services } var url = $"{_settings.BaseUrl}/api/privy{endpoint}"; + var idempotencyKey = Guid.NewGuid().ToString(); - try - { - var response = await _httpClient.PostAsJsonAsync(url, payload, _jsonOptions); - - if (!response.IsSuccessStatusCode) - { - await HandleErrorResponse(response); - } - - return await response.Content.ReadFromJsonAsync(_jsonOptions); - } - catch (Exception ex) when (!(ex is Web3ProxyException)) - { - SentrySdk.CaptureException(ex); - throw new Web3ProxyException($"Failed to call Privy service at {endpoint}: {ex.Message}"); - } + return await ExecuteWithRetryAsync( + () => { + var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonContent.Create(payload, options: _jsonOptions) + }; + request.Headers.Add("Idempotency-Key", idempotencyKey); + return _httpClient.SendAsync(request); + }, + $"CallPrivyServiceAsync({endpoint})", + idempotencyKey); } - public async Task GetPrivyServiceAsync(string endpoint, object payload = null) + public async Task GetPrivyServiceAsync(string endpoint, object? payload = null) { if (!endpoint.StartsWith("/")) { @@ -73,22 +158,9 @@ namespace Managing.Infrastructure.Evm.Services url += BuildQueryString(payload); } - try - { - var response = await _httpClient.GetAsync(url); - - if (!response.IsSuccessStatusCode) - { - await HandleErrorResponse(response); - } - - return await response.Content.ReadFromJsonAsync(_jsonOptions); - } - catch (Exception ex) when (!(ex is Web3ProxyException)) - { - SentrySdk.CaptureException(ex); - throw new Web3ProxyException($"Failed to get Privy service at {endpoint}: {ex.Message}"); - } + return await ExecuteWithRetryAsync( + () => _httpClient.GetAsync(url), + $"GetPrivyServiceAsync({endpoint})"); } public async Task CallGmxServiceAsync(string endpoint, object payload) @@ -99,26 +171,22 @@ namespace Managing.Infrastructure.Evm.Services } var url = $"{_settings.BaseUrl}/api/gmx{endpoint}"; + var idempotencyKey = Guid.NewGuid().ToString(); - try - { - var response = await _httpClient.PostAsJsonAsync(url, payload, _jsonOptions); - - if (!response.IsSuccessStatusCode) - { - await HandleErrorResponse(response); - } - - return await response.Content.ReadFromJsonAsync(_jsonOptions); - } - catch (Exception ex) when (!(ex is Web3ProxyException)) - { - SentrySdk.CaptureException(ex); - throw new Web3ProxyException($"Failed to call GMX service at {endpoint}: {ex.Message}"); - } + return await ExecuteWithRetryAsync( + () => { + var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonContent.Create(payload, options: _jsonOptions) + }; + request.Headers.Add("Idempotency-Key", idempotencyKey); + return _httpClient.SendAsync(request); + }, + $"CallGmxServiceAsync({endpoint})", + idempotencyKey); } - public async Task GetGmxServiceAsync(string endpoint, object payload = null) + public async Task GetGmxServiceAsync(string endpoint, object? payload = null) { if (!endpoint.StartsWith("/")) { @@ -132,22 +200,9 @@ namespace Managing.Infrastructure.Evm.Services url += BuildQueryString(payload); } - try - { - var response = await _httpClient.GetAsync(url); - - if (!response.IsSuccessStatusCode) - { - await HandleErrorResponse(response); - } - - return await response.Content.ReadFromJsonAsync(_jsonOptions); - } - catch (Exception ex) when (!(ex is Web3ProxyException)) - { - SentrySdk.CaptureException(ex); - throw new Web3ProxyException($"Failed to get GMX service at {endpoint}: {ex.Message}"); - } + return await ExecuteWithRetryAsync( + () => _httpClient.GetAsync(url), + $"GetGmxServiceAsync({endpoint})"); } public async Task GetGmxClaimableSummaryAsync(string account) diff --git a/src/Managing.Web3Proxy/README-REDIS.md b/src/Managing.Web3Proxy/README-REDIS.md new file mode 100644 index 00000000..c6c241ee --- /dev/null +++ b/src/Managing.Web3Proxy/README-REDIS.md @@ -0,0 +1,58 @@ +# Web3Proxy Redis Configuration + +## Environment Variables + +The Web3Proxy service now uses Redis for distributed idempotency storage across multiple instances. + +### Required Environment Variables + +- `REDIS_URL`: Redis connection string (default: `redis://localhost:6379`) +- `REDIS_PASSWORD`: Redis password (optional, for authenticated Redis instances) +- `LOG_LEVEL`: Logging level (default: `info`) + +### Docker Configuration + +When running in Docker, set the Redis URL to: +``` +REDIS_URL=redis://redis:6379 +``` + +For password-protected Redis instances: +``` +REDIS_URL=redis://redis:6379 +REDIS_PASSWORD=your_redis_password +``` + +### Production Configuration + +For production deployments with password-protected Redis: + +1. Set environment variables: + ```bash + export REDIS_URL=redis://your-redis-host:6379 + export REDIS_PASSWORD=your_secure_password + ``` + +2. Or use a connection string with embedded password: + ``` + REDIS_URL=redis://:your_password@your-redis-host:6379 + ``` + +### Fallback Behavior + +If Redis is not available, the service will automatically fall back to in-memory storage with a warning message. This ensures the service continues to work even without Redis, but idempotency will only work within a single instance. + +### Production Deployment + +For production deployments with multiple Web3Proxy instances: + +1. Ensure Redis is running and accessible +2. Set the `REDIS_URL` environment variable +3. Monitor Redis connection status in logs +4. Consider Redis clustering for high availability + +### Idempotency Key Format + +Idempotency keys are stored in Redis with the prefix `idempotency:` and have a TTL of 5 minutes. + +Example Redis key: `idempotency:123e4567-e89b-12d3-a456-426614174000` diff --git a/src/Managing.Web3Proxy/package-lock.json b/src/Managing.Web3Proxy/package-lock.json index d204f5cc..53b3b1d9 100644 --- a/src/Managing.Web3Proxy/package-lock.json +++ b/src/Managing.Web3Proxy/package-lock.json @@ -40,12 +40,14 @@ "mysql2": "^3.11.3", "postgrator": "^8.0.0", "query-string": "^9.1.1", + "redis": "^5.8.2", "viem": "2.37.1", "vitest": "^3.0.8", "zod": "^3.24.2" }, "devDependencies": { "@types/node": "^22.5.5", + "@types/redis": "^4.0.10", "c8": "^10.1.3", "eslint": "^9.11.0", "fastify-tsconfig": "^3.0.0", @@ -1521,6 +1523,66 @@ } } }, + "node_modules/@redis/bloom": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz", + "integrity": "sha512-855DR0ChetZLarblio5eM0yLwxA9Dqq50t8StXKp5bAtLT0G+rZ+eRzzqxl37sPqQKjUudSYypz55o6nNhbz0A==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.2" + } + }, + "node_modules/@redis/client": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.2.tgz", + "integrity": "sha512-WtMScno3+eBpTac1Uav2zugXEoXqaU23YznwvFgkPwBQVwEHTDgOG7uEAObtZ/Nyn8SmAMbqkEubJaMOvnqdsQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@redis/json": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.2.tgz", + "integrity": "sha512-uxpVfas3I0LccBX9rIfDgJ0dBrUa3+0Gc8sEwmQQH0vHi7C1Rx1Qn8Nv1QWz5bohoeIXMICFZRcyDONvum2l/w==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.2" + } + }, + "node_modules/@redis/search": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.2.tgz", + "integrity": "sha512-cNv7HlgayavCBXqPXgaS97DRPVWFznuzsAmmuemi2TMCx5scwLiP50TeZvUS06h/MG96YNPe6A0Zt57yayfxwA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.2" + } + }, + "node_modules/@redis/time-series": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.2.tgz", + "integrity": "sha512-g2NlHM07fK8H4k+613NBsk3y70R2JIM2dPMSkhIjl2Z17SYvaYKdusz85d7VYOrZBWtDrHV/WD2E3vGu+zni8A==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.2" + } + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.35.0", "cpu": [ @@ -1769,6 +1831,16 @@ "@types/pg": "*" } }, + "node_modules/@types/redis": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-4.0.10.tgz", + "integrity": "sha512-7CLy5b5fzzEGVcOccgZjoMlNpPhX6d10jEeRy2YWbFuaMNrSPc9ExRsMYsd+0VxvEHucf4EWx24Ja7cSU1FGUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "redis": "*" + } + }, "node_modules/@types/shimmer": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", @@ -2728,6 +2800,15 @@ "version": "2.2.0", "license": "MIT" }, + "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/color-convert": { "version": "2.0.1", "license": "MIT", @@ -6550,6 +6631,22 @@ "version": "0.5.1", "license": "Apache-2.0" }, + "node_modules/redis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.8.2.tgz", + "integrity": "sha512-31vunZj07++Y1vcFGcnNWEf5jPoTkGARgfWI4+Tk55vdwHxhAvug8VEtW7Cx+/h47NuJTEg/JL77zAwC6E0OeA==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.8.2", + "@redis/client": "5.8.2", + "@redis/json": "5.8.2", + "@redis/search": "5.8.2", + "@redis/time-series": "5.8.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "dev": true, diff --git a/src/Managing.Web3Proxy/package.json b/src/Managing.Web3Proxy/package.json index 97c6afe3..5e80ba9d 100644 --- a/src/Managing.Web3Proxy/package.json +++ b/src/Managing.Web3Proxy/package.json @@ -59,12 +59,14 @@ "mysql2": "^3.11.3", "postgrator": "^8.0.0", "query-string": "^9.1.1", + "redis": "^5.8.2", "viem": "2.37.1", "vitest": "^3.0.8", "zod": "^3.24.2" }, "devDependencies": { "@types/node": "^22.5.5", + "@types/redis": "^4.0.10", "c8": "^10.1.3", "eslint": "^9.11.0", "fastify-tsconfig": "^3.0.0", diff --git a/src/Managing.Web3Proxy/src/routes/home.ts b/src/Managing.Web3Proxy/src/routes/home.ts index e142b2ce..2eac5f2e 100644 --- a/src/Managing.Web3Proxy/src/routes/home.ts +++ b/src/Managing.Web3Proxy/src/routes/home.ts @@ -2,6 +2,7 @@ import {FastifyPluginAsyncTypebox, Type} from '@fastify/type-provider-typebox' import {handleError} from '../utils/errorHandler.js' import {getClientForAddress} from '../plugins/custom/gmx.js' import {getPrivyClient} from '../plugins/custom/privy.js' +import {createClient} from 'redis' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( @@ -48,6 +49,11 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { status: Type.String(), message: Type.String(), data: Type.Optional(Type.Any()) + }), + redis: Type.Object({ + status: Type.String(), + message: Type.String(), + data: Type.Optional(Type.Any()) }) }) }), @@ -59,9 +65,11 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } }, async function (request, reply) { try { + console.log('Checking health...') const checks = { privy: await checkPrivy(fastify), - gmx: await checkGmx() + gmx: await checkGmx(), + redis: await checkRedis() } // If any check failed, set status to degraded @@ -150,6 +158,96 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }; } } + + // Helper function to check Redis connectivity for idempotency + async function checkRedis() { + let redisClient = null; + try { + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + const redisPassword = process.env.REDIS_PASSWORD; + + console.log('Redis URL:', redisUrl) + console.log('Redis Password:', redisPassword) + + // Create Redis client configuration + const redisConfig: any = { url: redisUrl }; + // if (redisPassword) { + // redisConfig.password = redisPassword; + // } + + redisClient = createClient(redisConfig); + + // Set up error handling + redisClient.on('error', (err) => { + console.error('Redis health check error:', err); + }); + + // Connect to Redis + const startTime = Date.now(); + await redisClient.connect(); + const connectTime = Date.now() - startTime; + + // Test basic operations + const testKey = 'health-check-test'; + const testValue = JSON.stringify({ timestamp: Date.now(), test: true }); + + // Test SET operation + await redisClient.set(testKey, testValue, { EX: 10 }); // 10 second expiry + + // Test GET operation + const retrievedValue = await redisClient.get(testKey); + const getTime = Date.now() - startTime; + + // Test JSON parsing + const parsedValue = JSON.parse(retrievedValue as string); + + // Clean up test key + await redisClient.del(testKey); + + // Get Redis info + const info = await redisClient.info('server'); + const serverInfo = info.split('\r\n').reduce((acc, line) => { + const [key, value] = line.split(':'); + if (key && value) { + acc[key] = value; + } + return acc; + }, {} as Record); + + return { + status: 'healthy', + message: 'Redis connection successful', + data: { + connectTimeMs: connectTime, + getTimeMs: getTime, + redisVersion: serverInfo.redis_version, + uptimeSeconds: serverInfo.uptime_in_seconds, + connectedClients: serverInfo.connected_clients, + usedMemory: serverInfo.used_memory_human, + hasPassword: !!redisPassword + } + }; + } catch (error) { + return { + status: 'unhealthy', + message: `Redis connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + data: { + errorType: error instanceof Error ? error.constructor.name : 'Unknown', + redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', + hasPassword: !!process.env.REDIS_PASSWORD + } + }; + } finally { + // Always close the Redis connection + if (redisClient && redisClient.isOpen) { + try { + await redisClient.quit(); + } catch (closeError) { + console.error('Error closing Redis connection in health check:', closeError); + } + } + } + } } export default plugin diff --git a/src/Managing.Web3Proxy/src/server.ts b/src/Managing.Web3Proxy/src/server.ts index 10461159..69d2513e 100644 --- a/src/Managing.Web3Proxy/src/server.ts +++ b/src/Managing.Web3Proxy/src/server.ts @@ -7,6 +7,7 @@ import Fastify from 'fastify' import fp from 'fastify-plugin' +import {createClient, RedisClientType} from 'redis' // Import library to exit fastify process, gracefully (if possible) import closeWithGrace from 'close-with-grace' @@ -14,6 +15,94 @@ import closeWithGrace from 'close-with-grace' // Import your application as a normal plugin. import serviceApp from './app.js' +// Idempotency storage using Redis +interface IdempotencyEntry { + requestId: string + response: any + statusCode: number + timestamp: number + ttl: number // Time to live in milliseconds +} + +let redisClient: RedisClientType | null = null +const IDEMPOTENCY_TTL = 5 * 60 // 5 minutes TTL in seconds (Redis uses seconds) + +// Initialize Redis connection +async function initializeRedis() { + try { + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' + const redisPassword = process.env.REDIS_PASSWORD + + console.log('Redis URL:', redisUrl) + console.log('Redis Password:', redisPassword) + + // Create Redis client with password support + const redisConfig: any = { url: redisUrl } + + if (redisPassword) { + redisConfig.password = redisPassword + } + + redisClient = createClient(redisConfig) + + redisClient.on('error', (err) => { + console.error('Redis Client Error:', err) + }) + + redisClient.on('connect', () => { + console.log('Connected to Redis for idempotency') + }) + + redisClient.on('ready', () => { + console.log('Redis client ready for idempotency operations') + }) + + await redisClient.connect() + } catch (error) { + console.error('Failed to connect to Redis:', error) + // Fallback to in-memory storage if Redis is not available + console.warn('Falling back to in-memory idempotency storage') + } +} + +// Fallback in-memory storage for when Redis is not available +const fallbackStore = new Map() + +// Helper function to get idempotency entry +async function getIdempotencyEntry(requestId: string): Promise { + if (redisClient && redisClient.isOpen) { + try { + const data = await redisClient.get(`idempotency:${requestId}`) + if (data && typeof data === 'string') { + return JSON.parse(data) + } + return null + } catch (error) { + console.error('Redis get error:', error) + return null + } + } else { + // Fallback to in-memory storage + return fallbackStore.get(requestId) || null + } +} + +// Helper function to set idempotency entry +async function setIdempotencyEntry(requestId: string, entry: IdempotencyEntry): Promise { + if (redisClient && redisClient.isOpen) { + try { + await redisClient.setEx(`idempotency:${requestId}`, IDEMPOTENCY_TTL, JSON.stringify(entry)) + } catch (error) { + console.error('Redis set error:', error) + // Fallback to in-memory storage + fallbackStore.set(requestId, entry) + } + } else { + // Fallback to in-memory storage + fallbackStore.set(requestId, entry) + } +} + /** * Do not use NODE_ENV to determine what logger (or any env related feature) to use * @see {@link https://www.youtube.com/watch?v=HMM7GJC5E2o} @@ -33,7 +122,7 @@ function getLoggerOptions () { } } - return { level: process.env.LOG_LEVEL ?? 'silent' } + return { level: process.env.LOG_LEVEL ?? 'info' } // Changed from 'silent' to 'info' for better debugging } const app = Fastify({ @@ -43,22 +132,108 @@ const app = Fastify({ coerceTypes: 'array', // change type of data to match type keyword removeAdditional: 'all' // Remove additional body properties } - } + }, + // Add connection and timeout settings for better resilience + connectionTimeout: 30000, // 30 seconds + keepAliveTimeout: 5000, // 5 seconds + bodyLimit: 1048576, // 1MB + maxParamLength: 200, // 200 characters + // Add request timeout + requestTimeout: 30000, // 30 seconds }) async function init () { + // Initialize Redis connection + await initializeRedis() + + // Add idempotency pre-handler hook + app.addHook('preHandler', async (request, reply) => { + // Only apply idempotency to POST requests (trading operations) + if (request.method !== 'POST') { + return + } + + const requestId = request.headers['idempotency-key'] || request.headers['x-request-id'] + + if (!requestId) { + // No idempotency key provided, continue normally + return + } + + // Check if we've seen this request before + const existingEntry = await getIdempotencyEntry(requestId as string) + + if (existingEntry) { + // Check if entry is still valid (Redis TTL handles this, but double-check for fallback) + const now = Date.now() + if (now - existingEntry.timestamp <= existingEntry.ttl) { + app.log.info(`Idempotency: Returning cached response for request ${requestId}`) + + // Return the cached response + reply.code(existingEntry.statusCode) + return existingEntry.response + } else { + // Entry expired, remove it (only needed for fallback storage) + if (!redisClient || !redisClient.isOpen) { + fallbackStore.delete(requestId as string) + } + } + } + + // Store the request ID for later use + request.idempotencyKey = requestId as string + }) + + // Add post-handler hook to store successful responses + app.addHook('onSend', async (request, reply, payload) => { + if (request.idempotencyKey && request.method === 'POST') { + const requestId = request.idempotencyKey + + // Only store successful responses (2xx status codes) + if (reply.statusCode >= 200 && reply.statusCode < 300) { + try { + const responseData = typeof payload === 'string' ? JSON.parse(payload) : payload + + const entry: IdempotencyEntry = { + requestId, + response: responseData, + statusCode: reply.statusCode, + timestamp: Date.now(), + ttl: IDEMPOTENCY_TTL * 1000 // Convert to milliseconds for consistency + } + + await setIdempotencyEntry(requestId, entry) + + app.log.info(`Idempotency: Stored response for request ${requestId}`) + } catch (error) { + app.log.error(`Idempotency: Failed to store response for request ${requestId}:`, error) + } + } + } + }) + // Register your application as a normal plugin. // fp must be used to override default error handler app.register(fp(serviceApp)) // Delay is the number of milliseconds for the graceful close to finish closeWithGrace( - { delay: process.env.FASTIFY_CLOSE_GRACE_DELAY ?? 500 }, + { delay: process.env.FASTIFY_CLOSE_GRACE_DELAY ?? 5000 }, // Increased from 500ms to 5s async ({ err }) => { if (err != null) { app.log.error(err) } + // Close Redis connection gracefully + if (redisClient && redisClient.isOpen) { + try { + await redisClient.quit() + console.log('Redis connection closed gracefully') + } catch (error) { + console.error('Error closing Redis connection:', error) + } + } + await app.close() } ) @@ -66,8 +241,14 @@ async function init () { await app.ready() try { - // Start listening. - await app.listen({ port: 4111 }) + // Start listening with better configuration + await app.listen({ + port: 4111, + host: '0.0.0.0', // Listen on all interfaces + backlog: 511, // Increase backlog for better connection handling + }) + + app.log.info('Web3Proxy server started successfully on port 4111') } catch (err) { app.log.error(err) process.exit(1) diff --git a/src/Managing.Web3Proxy/src/types/fastify.d.ts b/src/Managing.Web3Proxy/src/types/fastify.d.ts new file mode 100644 index 00000000..5da9b42a --- /dev/null +++ b/src/Managing.Web3Proxy/src/types/fastify.d.ts @@ -0,0 +1,5 @@ +declare module 'fastify' { + interface FastifyRequest { + idempotencyKey?: string + } +} diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 4366a7dc..89487fb5 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -18,6 +18,29 @@ services: networks: - managing-network + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - managing-network + restart: unless-stopped + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + command: > + sh -c " + if [ -n \"$$REDIS_PASSWORD\" ]; then + redis-server --appendonly yes --requirepass $$REDIS_PASSWORD + else + redis-server --appendonly yes + fi + " + +volumes: + redis_data: + networks: managing-network: external: