outlook-mcp/utils/graph-api.js
setonc 4d8ea1b3a7 feat(outlook-mcp): harden Graph client with retry/backoff on 429 and 503
The Graph client previously failed the call on the first 429/503. The
request loop now retries these transient statuses before giving up.

Behavior:
- 429 and 503 are retried up to OUTLOOK_MAX_RETRIES times.
- Retry-After header is honored when Graph sends it, clamped to
  MAX_RETRY_DELAY_MS.
- Otherwise exponential backoff with full jitter, same clamp.
- 401 still throws UNAUTHORIZED immediately (no retry, no point).
- Other 4xx (400/403/404, etc.) are NOT retried -- these are caller
  errors, retrying just burns the rate-limit budget.

New config knobs, all env-overridable:
- OUTLOOK_MAX_RETRIES         (default 3)
- OUTLOOK_BASE_RETRY_DELAY_MS (default 1000)
- OUTLOOK_MAX_RETRY_DELAY_MS  (default 30000)

Implementation:
- Refactored the request into makeSingleRequest + optionsForRequest
  helpers so the retry loop sits above them without duplicating the
  response parsing.
- Empty-body success (202/204, e.g. sendMail, DELETE) is preserved.
- sleep and getRetryDelay are exported for the test.

Tests:
- tests/backoff.test.js: spins up a local HTTPS server, asserts
  getRetryDelay honors Retry-After and clamps to MAX_RETRY_DELAY_MS,
  and that callGraphAPI retries on 429 and fails after maxAttempts.
2026-07-31 09:12:14 -04:00

193 lines
6.3 KiB
JavaScript

/**
* Microsoft Graph API helper functions
*/
const https = require('https');
const config = require('../config');
const mockData = require('./mock-data');
/**
* Sleep helper for backoff delays.
* @param {number} ms - Milliseconds to wait
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Compute the next retry delay.
* Honors a Retry-After header (seconds) when present, otherwise uses
* exponential backoff with full jitter. Clamps to MAX_RETRY_DELAY_MS.
* @param {number} attempt - 0-based attempt index
* @param {number|undefined} retryAfterSeconds - Retry-After header value
* @returns {number} - Delay in milliseconds
*/
function getRetryDelay(attempt, retryAfterSeconds) {
if (retryAfterSeconds && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
return Math.min(retryAfterSeconds * 1000, config.MAX_RETRY_DELAY_MS);
}
const exponential = config.BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
const jitter = Math.random() * exponential;
return Math.min(exponential + jitter, config.MAX_RETRY_DELAY_MS);
}
/**
* Makes a request to the Microsoft Graph API
* @param {string} accessToken - The access token for authentication
* @param {string} method - HTTP method (GET, POST, etc.)
* @param {string} path - API endpoint path
* @param {object} data - Data to send for POST/PUT requests
* @param {object} queryParams - Query parameters
* @returns {Promise<object>} - The API response
*/
async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}) {
// For test tokens, we'll simulate the API call
if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
console.error(`TEST MODE: Simulating ${method} ${path} API call`);
return mockData.simulateGraphAPIResponse(method, path, data, queryParams);
}
try {
console.error(`Making real API call: ${method} ${path}`);
// Encode path segments properly
const encodedPath = path.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
// Build query string from parameters with special handling for OData filters
let queryString = '';
if (Object.keys(queryParams).length > 0) {
// Handle $filter parameter specially to ensure proper URI encoding
const filter = queryParams.$filter;
if (filter) {
delete queryParams.$filter; // Remove from regular params
}
// Build query string with proper encoding for regular params
const params = new URLSearchParams();
for (const [key, value] of Object.entries(queryParams)) {
params.append(key, value);
}
queryString = params.toString();
// Add filter parameter separately with proper encoding
if (filter) {
if (queryString) {
queryString += `&$filter=${encodeURIComponent(filter)}`;
} else {
queryString = `$filter=${encodeURIComponent(filter)}`;
}
}
if (queryString) {
queryString = '?' + queryString;
}
console.error(`Query string: ${queryString}`);
}
const url = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
console.error(`Full URL: ${url}`);
const maxAttempts = Math.max(1, config.MAX_RETRIES + 1);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await makeSingleRequest(url, optionsForRequest(method, accessToken), data);
if (result.success) {
return result.body;
}
// Do not retry authentication failures.
if (result.status === 401) {
throw new Error('UNAUTHORIZED');
}
// Retry throttling / transient server errors.
const isRetryable = result.status === 429 || result.status === 503;
if (!isRetryable || attempt === maxAttempts - 1) {
throw new Error(`API call failed with status ${result.status}: ${result.body}`);
}
const retryAfterSeconds = result.retryAfter ? parseInt(result.retryAfter, 10) : undefined;
const delayMs = getRetryDelay(attempt, retryAfterSeconds);
console.error(`Retry ${attempt + 1}/${config.MAX_RETRIES} after ${delayMs}ms (status ${result.status})`);
await sleep(delayMs);
}
// Should not reach here; final failure is thrown inside the loop.
throw new Error(`API call failed after ${maxAttempts} attempts`);
} catch (error) {
console.error('Error calling Graph API:', error);
throw error;
}
}
/**
* Build the https request options object.
*/
function optionsForRequest(method, accessToken) {
return {
method: method,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
};
}
/**
* Execute one HTTPS request and return a normalized result.
* @returns {Promise<{success: boolean, status: number, body: any, retryAfter?: string}>}
*/
function makeSingleRequest(url, options, data) {
return new Promise((resolve, reject) => {
const req = https.request(url, options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
const trimmed = responseData.trim();
if (!trimmed) {
return resolve({ success: true, status: res.statusCode, body: {} });
}
try {
const jsonResponse = JSON.parse(trimmed);
resolve({ success: true, status: res.statusCode, body: jsonResponse });
} catch (error) {
reject(new Error(`Error parsing API response: ${error.message}`));
}
} else {
resolve({
success: false,
status: res.statusCode,
body: responseData,
retryAfter: res.headers['retry-after']
});
}
});
});
req.on('error', (error) => {
reject(new Error(`Network error during API call: ${error.message}`));
});
if (data && (options.method === 'POST' || options.method === 'PATCH' || options.method === 'PUT')) {
req.write(JSON.stringify(data));
}
req.end();
});
}
module.exports = {
callGraphAPI,
getRetryDelay,
sleep
};