Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions backend/docs/EXTERNAL_SERVICE_POLICIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# External Service Timeout and Retry Policies

This document outlines the centralized management of timeouts and retries for external dependencies in the SYNCRO backend.

## Centralized Client

All external HTTP requests should use the `ExternalServiceClient` located in `backend/src/utils/external-service-client.ts`. This client provides:

1. **Service-specific Timeouts**: Prevents external latencies from hanging the backend.
2. **Retry Policies**: Implements exponential backoff with jitter for transient failures.
3. **Metrics Tracking**: Records total requests, successes, failures, and timeouts per service.
4. **Admin Exposure**: Metrics are available via the `/api/v1/admin/metrics/external-services` endpoint.

## Configuration

Policies are defined in `backend/src/config/external-services.ts`.

| Service | Timeout (ms) | Max Retries | Initial Delay (ms) |
| :--- | :--- | :--- | :--- |
| **Gmail** | 15,000 | 3 | 1,000 |
| **Outlook** | 15,000 | 3 | 1,000 |
| **Stellar RPC** | 5,000 | 5 | 500 |
| **Stripe** | 10,000 | 2 | 1,000 |
| **Paystack** | 10,000 | 2 | 1,000 |
| **Exchange Rates** | 5,000 | 3 | 1,000 |
| **LLM (Gemini)** | 30,000 | 2 | 2,000 |
| **Outbound Webhooks** | 10,000 | 5 | 2,000 |
| **Slack** | 5,000 | 3 | 1,000 |
| **Telegram** | 10,000 | 3 | 1,000 |
| **Default** | 10,000 | 3 | 1,000 |

## Usage Example

```typescript
import { ExternalServiceClient } from '../utils/external-service-client';

const client = new ExternalServiceClient('exchange_rates');

async function getRates() {
const data = await client.request('https://api.exchangerate-api.com/v4/latest/USD');
return data;
}
```

## Metrics Monitoring

Admin users can monitor service health via:
`GET /api/v1/admin/metrics/external-services`

Example response:
```json
{
"exchange_rates": {
"totalRequests": 150,
"successfulRequests": 148,
"failedRequests": 2,
"timeoutRequests": 1,
"retryCount": 5
}
}
```
14 changes: 12 additions & 2 deletions backend/services/gmail-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { parseSubscriptionEmail } from "./email-parser";
import { generateProofHash, hashContent } from "../utils/proof-hashing";
import { metadataExtractionOnly } from "./email-scanner";
import type { RawScanResult } from "./email-scanner";
import { EXTERNAL_SERVICE_POLICIES } from "../src/config/external-services";

const policy = EXTERNAL_SERVICE_POLICIES.gmail;
const GMAIL_SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"];

const KEYWORDS = [
Expand Down Expand Up @@ -76,7 +78,11 @@ export async function exchangeGmailCodeForTokens(
export async function getGmailProfile(tokens: Credentials) {
const oauth2Client = createOAuthClient();
oauth2Client.setCredentials(tokens);
const gmail = google.gmail({ version: "v1", auth: oauth2Client });
const gmail = google.gmail({
version: "v1",
auth: oauth2Client,
timeout: policy.timeoutMs,
});
const profile = await gmail.users.getProfile({ userId: "me" });
return profile.data;
}
Expand All @@ -93,7 +99,11 @@ export async function scanGmailSubscriptions({
refresh_token: refreshToken,
});

const gmail = google.gmail({ version: "v1", auth: oauth2Client });
const gmail = google.gmail({
version: "v1",
auth: oauth2Client,
timeout: policy.timeoutMs,
});
const query = buildQuery(sinceDays);

const listResponse = await gmail.users.messages.list({
Expand Down
30 changes: 6 additions & 24 deletions backend/services/outlook-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { parseSubscriptionEmail } from "./email-parser";
import { generateProofHash, hashContent } from "../utils/proof-hashing";
import { metadataExtractionOnly } from "./email-scanner";
import type { RawScanResult } from "./email-scanner";
import { ExternalServiceClient } from "../src/utils/external-service-client";

const outlookClient = new ExternalServiceClient('outlook');
const OUTLOOK_SCOPES = ["offline_access", "User.Read", "Mail.Read"];

const KEYWORDS = [
Expand Down Expand Up @@ -87,16 +89,9 @@ export async function refreshOutlookToken(
export async function getOutlookProfile(
accessToken: string,
): Promise<OutlookProfile> {
const response = await fetch("https://graph.microsoft.com/v1.0/me", {
return outlookClient.request<OutlookProfile>("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!response.ok) {
const error = await response.text();
throw new Error(`Outlook profile fetch failed: ${error}`);
}

return response.json() as Promise<OutlookProfile>;
}

export async function scanOutlookSubscriptions({
Expand All @@ -118,20 +113,14 @@ export async function scanOutlookSubscriptions({
url.searchParams.set("$select", "id,subject,from,receivedDateTime,body");
url.searchParams.set("$top", String(maxResults));

const response = await fetch(url.toString(), {
const data = await outlookClient.request<{ value?: any[] }>(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
ConsistencyLevel: "eventual",
Prefer: 'outlook.body-content-type="text"',
},
});

if (!response.ok) {
const error = await response.text();
throw new Error(`Outlook message scan failed: ${error}`);
}

const data = (await response.json()) as { value?: any[] };
const results: RawScanResult[] = [];

for (const message of data.value ?? []) {
Expand Down Expand Up @@ -193,19 +182,12 @@ async function requestOutlookToken(
});

const tenant = process.env.MICROSOFT_TENANT_ID ?? "common";
const response = await fetch(
return outlookClient.request<OutlookTokenResponse>(
`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
body: body.toString(),
},
);

if (!response.ok) {
const error = await response.text();
throw new Error(`Outlook token exchange failed: ${error}`);
}

return response.json() as Promise<OutlookTokenResponse>;
}
6 changes: 3 additions & 3 deletions backend/services/paystack.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logger from '../config/logger';
import { ExternalServiceClient } from '../src/utils/external-service-client';

const paystackClient = new ExternalServiceClient('paystack');
const PAYSTACK_BASE = 'https://api.paystack.co';
const SECRET_KEY = process.env.PAYSTACK_SECRET_KEY ?? '';

Expand All @@ -8,7 +10,7 @@ async function paystackRequest<T>(
path: string,
body?: Record<string, unknown>
): Promise<T> {
const res = await fetch(`${PAYSTACK_BASE}${path}`, {
const json = await paystackClient.request<{ status: boolean; message: string; data: T }>(`${PAYSTACK_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${SECRET_KEY}`,
Expand All @@ -17,8 +19,6 @@ async function paystackRequest<T>(
body: body ? JSON.stringify(body) : undefined,
});

const json = (await res.json()) as { status: boolean; message: string; data: T };

if (!json.status) {
throw new Error(`Paystack error: ${json.message}`);
}
Expand Down
4 changes: 4 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ const envSchema = z.object({

// Risk calculation concurrency (number of simultaneous risk calculations per page)
RISK_CALC_CONCURRENCY: z.string().default('10'),

// External Service Defaults
EXTERNAL_SERVICE_DEFAULT_TIMEOUT: z.string().default('10000'),
EXTERNAL_SERVICE_DEFAULT_RETRIES: z.string().default('3'),
});

function validateEnv() {
Expand Down
154 changes: 154 additions & 0 deletions backend/src/config/external-services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { RetryOptions } from '../utils/retry';

export interface ServicePolicy {
timeoutMs: number;
retryPolicy: RetryOptions;
}

/**
* Centralized policies for external service dependencies.
* Each service has a specific timeout and retry policy based on its
* typical latency and failure modes.
*/
export const EXTERNAL_SERVICE_POLICIES: Record<string, ServicePolicy> = {
// Gmail API (Google OAuth/Mail)
gmail: {
timeoutMs: 15000,
retryPolicy: {
maxAttempts: 3,
initialDelay: 1000,
maxDelay: 5000,
multiplier: 2,
jitter: true,
},
},

// Outlook API (Microsoft Graph)
outlook: {
timeoutMs: 15000,
retryPolicy: {
maxAttempts: 3,
initialDelay: 1000,
maxDelay: 5000,
multiplier: 2,
jitter: true,
},
},

// Stellar / Soroban RPC
stellar_rpc: {
timeoutMs: 5000,
retryPolicy: {
maxAttempts: 5,
initialDelay: 500,
maxDelay: 2000,
multiplier: 1.5,
jitter: true,
},
},

// Stripe API
stripe: {
timeoutMs: 10000,
retryPolicy: {
maxAttempts: 2,
initialDelay: 1000,
maxDelay: 4000,
multiplier: 2,
jitter: true,
},
},

// Paystack API
paystack: {
timeoutMs: 10000,
retryPolicy: {
maxAttempts: 2,
initialDelay: 1000,
maxDelay: 4000,
multiplier: 2,
jitter: true,
},
},

// Fiat Exchange Rate APIs (ExchangeRate-API, Frankfurter)
exchange_rates: {
timeoutMs: 5000,
retryPolicy: {
maxAttempts: 3,
initialDelay: 1000,
maxDelay: 3000,
multiplier: 2,
jitter: true,
},
},

// LLM Services (Gemini)
llm: {
timeoutMs: 30000,
retryPolicy: {
maxAttempts: 2,
initialDelay: 2000,
maxDelay: 10000,
multiplier: 2,
jitter: true,
},
},

// Outbound Webhooks (user-defined)
outbound_webhooks: {
timeoutMs: 10000,
retryPolicy: {
maxAttempts: 5,
initialDelay: 2000,
maxDelay: 60000,
multiplier: 2,
jitter: true,
},
},

// Slack Notifications
slack: {
timeoutMs: 5000,
retryPolicy: {
maxAttempts: 3,
initialDelay: 1000,
maxDelay: 5000,
multiplier: 2,
jitter: true,
},
},

// Telegram Bot API
telegram: {
timeoutMs: 10000,
retryPolicy: {
maxAttempts: 3,
initialDelay: 1000,
maxDelay: 5000,
multiplier: 2,
jitter: true,
},
},

// Default policy for unspecified services
default: {
timeoutMs: 10000,
retryPolicy: {
maxAttempts: 3,
initialDelay: 1000,
maxDelay: 30000,
multiplier: 2,
jitter: true,
},
},
};

export type ServiceName = keyof typeof EXTERNAL_SERVICE_POLICIES;

/**
* Gets the policy for a given service name, falling back to the default policy if not found.
*/
export function getServicePolicy(serviceName: string): ServicePolicy {
return EXTERNAL_SERVICE_POLICIES[serviceName] || EXTERNAL_SERVICE_POLICIES.default;
}
9 changes: 9 additions & 0 deletions backend/src/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ v1Router.get('/admin/metrics/renewals', adminAuth, async (req: express.Request,
}
});

v1Router.get('/admin/metrics/external-services', adminAuth, async (req: express.Request, res: express.Response) => {
try {
const metrics = monitoringService.getExternalServiceMetrics();
res.json(metrics);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch external service metrics' });
}
});

v1Router.get('/admin/metrics/activity', adminAuth, async (req: express.Request, res: express.Response) => {
try {
const metrics = await monitoringService.getAgentActivity();
Expand Down
Loading
Loading