fix(azure-speech): add timeout to voices list request (#102984)

* fix(azure-speech): add timeout to voices list request

* test(azure-speech): simplify voice timeout proof

* test(azure-speech): mark voice keys as placeholders

---------

Co-authored-by: chengzhichao-xydt <chengzhichao-xydt@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
chengzhichao-xydt
2026-07-13 17:25:13 +08:00
committed by GitHub
parent 95d9457465
commit 1ed04910e2
3 changed files with 143 additions and 1 deletions
+4 -1
View File
@@ -25,6 +25,9 @@ export const DEFAULT_AZURE_SPEECH_VOICE_NOTE_FORMAT = "ogg-24khz-16bit-mono-opus
/** Default telephony output format. */
export const DEFAULT_AZURE_SPEECH_TELEPHONY_FORMAT = "raw-8khz-8bit-mono-mulaw";
const DEFAULT_AZURE_SPEECH_MAX_BYTES = 16 * 1024 * 1024;
// Voice discovery should fail boundedly instead of waiting forever when the
// Azure Speech voices endpoint accepts the connection but never responds.
const DEFAULT_AZURE_SPEECH_VOICE_LIST_TIMEOUT_MS = 30_000;
type AzureSpeechVoiceEntry = {
ShortName?: string;
@@ -156,7 +159,7 @@ export async function listAzureSpeechVoices(params: {
"Ocp-Apim-Subscription-Key": params.apiKey,
},
},
timeoutMs: params.timeoutMs,
timeoutMs: params.timeoutMs ?? DEFAULT_AZURE_SPEECH_VOICE_LIST_TIMEOUT_MS,
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(url),
auditContext: "azure-speech.voices",
});
@@ -0,0 +1,66 @@
// Azure Speech voice list default timeout unit tests.
import { afterEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
};
});
import { listAzureSpeechVoices } from "./tts.js";
type GuardRequest = {
url: string;
init?: RequestInit;
timeoutMs?: number;
policy?: unknown;
auditContext?: string;
};
function queueGuardedResponse(response: Response): { release: ReturnType<typeof vi.fn> } {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
return { release };
}
function lastGuardRequest(): GuardRequest {
const calls = fetchWithSsrFGuardMock.mock.calls;
const call = calls[calls.length - 1];
if (!call) {
throw new Error("fetchWithSsrFGuard was not called");
}
return call[0] as GuardRequest;
}
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
vi.restoreAllMocks();
});
describe("listAzureSpeechVoices default timeout", () => {
it("defaults to a bounded timeout for voice list requests", async () => {
queueGuardedResponse(new Response(JSON.stringify([]), { status: 200 }));
await listAzureSpeechVoices({ apiKey: "not-a-real", region: "eastus" });
expect(lastGuardRequest().timeoutMs).toBe(30_000);
});
it("preserves an explicit timeout for voice list requests", async () => {
queueGuardedResponse(new Response(JSON.stringify([]), { status: 200 }));
await listAzureSpeechVoices({
apiKey: "not-a-real",
region: "eastus",
timeoutMs: 5_000,
});
expect(lastGuardRequest().timeoutMs).toBe(5_000);
});
});
@@ -0,0 +1,73 @@
// Azure Speech voice list timeout integration proof.
// A loopback server accepts the connection but never responds so this exercises
// the real fetch abort path without depending on Azure latency.
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import { listAzureSpeechVoices } from "./tts.js";
async function listenLocal(server: Server): Promise<number> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
return (server.address() as AddressInfo).port;
}
async function closeServer(server: Server): Promise<void> {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
describe("listAzureSpeechVoices timeout", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
vi.unstubAllGlobals();
});
it("aborts a hanging voice list request within the configured timeout", async () => {
let requestCount = 0;
const server = createServer((_req, _res) => {
requestCount += 1;
});
const port = await listenLocal(server);
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
return await originalFetch(`http://127.0.0.1:${port}/cognitiveservices/voices/list`, init);
}) as unknown as typeof globalThis.fetch,
);
const startedAt = Date.now();
try {
await expect(
Promise.race([
listAzureSpeechVoices({
apiKey: "not-a-real",
baseUrl: "https://custom.example.com",
timeoutMs: 250,
}),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("voices list did not time out")), 2_000);
}),
]),
).rejects.toThrow(/aborted|timeout|timed out/i);
expect(Date.now() - startedAt).toBeLessThan(2_000);
expect(requestCount).toBe(1);
} finally {
await closeServer(server);
}
});
});