Fix #4: remove global axios-retry side-effect; add dedicated downloadClient

- wxccRoutes.js no longer mutates the default axios instance, which was
  causing every bare axios call in the codebase (S3 downloads, Assets
  diagnostics) to inherit retries as a side-effect of route file load order.
- jiraService.js now defines a private downloadClient (own timeout, own
  retry policy) used by attachFileToJira and fetchAndConvertTranscript for
  fetching pre-signed S3 URLs.
- Assets AQL/GET remain bare axios calls; they're one-shot diagnostics
  and should not auto-retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-01 16:28:47 -04:00
parent 1070967870
commit c4a0a6934e
2 changed files with 26 additions and 20 deletions

View file

@ -1,22 +1,9 @@
import express from 'express';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import { logger, webexLogger } from '../utilities/logger.js';
import * as jiraService from '../services/jiraService.js';
import grokService from '../services/grokService.js';
import config from '../config/index.js';
// Configure retry for transient failures
axiosRetry(axios, {
retries: 3,
retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000,
retryCondition: (error) => {
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
error.response?.status === 429 ||
error.response?.status >= 500;
}
});
const router = express.Router();
// ========================

View file

@ -30,9 +30,11 @@ const createJiraClient = () => {
headers,
});
// Apply retry policy to this instance (keep in sync with the global axios-retry
// configured in wxccRoutes.js for the S3 downloads and any other raw calls).
// Using exponential backoff to match the current policy in routes.
// Retry policy scoped to this client only. We used to also mutate the
// global axios instance from wxccRoutes.js, which caused every bare axios
// call (S3 downloads, Assets AQL, etc.) to inherit these retries as a
// side-effect. That's been removed — anything that needs retries now uses
// an explicit instance (jiraClient here, downloadClient below).
axiosRetry(client, {
retries: 3,
retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000,
@ -48,6 +50,24 @@ const createJiraClient = () => {
export const jiraClient = createJiraClient();
// Dedicated instance for fetching pre-signed S3 URLs (audio + transcript
// files that Webex CC hands us). Kept separate from jiraClient because:
// 1. Different base URL (no baseURL — we always pass the full pre-signed URL).
// 2. No Authorization header (the S3 URL is already signed).
// 3. We want retries here — S3 pre-signed downloads are the flakiest thing
// in the pipeline (transient 5xx, TLS resets, TCP timeouts).
// Kept private (not exported); callers in this module use it directly.
const downloadClient = axios.create({ timeout: 20000 });
axiosRetry(downloadClient, {
retries: 3,
retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000,
retryCondition: (error) => {
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
error.response?.status === 429 ||
error.response?.status >= 500;
}
});
// ========================
// Existing Functions (updated to use jiraClient where possible)
// ========================
@ -256,9 +276,8 @@ async function attachBufferToJira(jiraKey, fileBuffer, fileName) {
export async function attachFileToJira(jiraKey, fileUrl, fileName) {
let fileBuffer;
try {
const dl = await axios.get(fileUrl, {
responseType: 'arraybuffer',
timeout: 20000
const dl = await downloadClient.get(fileUrl, {
responseType: 'arraybuffer'
});
fileBuffer = Buffer.from(dl.data);
} catch (dlErr) {
@ -308,7 +327,7 @@ function formatTranscriptToHumanReadable(data) {
async function fetchAndConvertTranscript(url) {
try {
const resp = await axios.get(url, { timeout: 10000 });
const resp = await downloadClient.get(url, { timeout: 10000 });
return formatTranscriptToHumanReadable(resp.data);
} catch (e) {
logger.warn(`Failed to fetch/convert transcript: ${e.message}`);