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: