dandi commited on
Commit
1788deb
·
1 Parent(s): 4da59fb

fix: key rotation - zero delay on 402, 55 retries, handler timeout 55s

Browse files
Files changed (2) hide show
  1. api/lib/proxy.js +27 -21
  2. api/v1/chat/completions.js +8 -8
api/lib/proxy.js CHANGED
@@ -1,13 +1,13 @@
1
  const RETRYABLE_STATUSES = new Set([402, 429, 500, 502, 503, 504, 524]);
2
- const DEFAULT_MAX_RETRIES = 5;
3
- const BASE_DELAY_MS = 500;
4
  const MAX_DELAY_MS = 5000;
5
- const BACKOFF_FACTOR = 2;
6
- const FETCH_TIMEOUT_MS = 15000;
7
 
8
  function computeDelay(attempt, random = Math.random) {
9
  const planned = Math.min(BASE_DELAY_MS * Math.pow(BACKOFF_FACTOR, attempt - 1), MAX_DELAY_MS);
10
- return Math.max(planned * random(), 100);
11
  }
12
 
13
  function parseRetryAfterMs(response) {
@@ -37,28 +37,36 @@ async function proxyToKimchi(options) {
37
  requestHeaders = {},
38
  requestBody,
39
  method = "POST",
 
40
  retry = {},
41
  timeoutMs = FETCH_TIMEOUT_MS,
42
  signal,
43
  } = options;
44
 
45
- const maxRetries = retry.maxRetries ?? DEFAULT_MAX_RETRIES;
46
  const lastError = [];
47
  let currentKey = null;
48
  let currentIndex = 0;
49
 
50
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
 
 
 
 
51
  const keyInfo = getNextKey();
52
  currentKey = keyInfo.key;
53
  currentIndex = keyInfo.index;
54
 
55
  const ctrl = new AbortController();
56
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
57
- const finalSignal = signal
58
- ? typeof AbortSignal.any === "function"
59
- ? AbortSignal.any([ctrl.signal, signal])
60
- : ctrl.signal
61
- : ctrl.signal;
 
 
 
62
 
63
  try {
64
  const response = await fetch(upstreamUrl, {
@@ -84,26 +92,24 @@ async function proxyToKimchi(options) {
84
 
85
  if (isCreditExhausted(response, responseText)) {
86
  lastError.push(new Error(`HTTP ${response.status}: credits exhausted (key ${currentIndex})`));
87
- if (attempt < maxRetries) {
88
- await new Promise((resolve) => setTimeout(resolve, 50));
89
- continue;
90
- }
91
  }
92
 
93
- lastError.push(new Error(`HTTP ${response.status}`));
94
 
95
- const retryAfterMs = parseRetryAfterMs(response);
96
- const delay = retryAfterMs !== null ? Math.max(computeDelay(attempt), retryAfterMs) : computeDelay(attempt);
97
- await new Promise((resolve) => setTimeout(resolve, delay));
 
 
98
  } catch (error) {
99
  clearTimeout(timer);
100
- if (signal?.aborted) throw error;
101
 
102
  lastError.push(error instanceof Error ? error : new Error(String(error)));
103
 
104
  if (attempt === maxRetries) {
105
  throw new Error(
106
- `Proxy failed after ${maxRetries} attempts: ${lastError.map((e) => e.message).join("; ")}`,
107
  );
108
  }
109
 
 
1
  const RETRYABLE_STATUSES = new Set([402, 429, 500, 502, 503, 504, 524]);
2
+ const DEFAULT_MAX_RETRIES = 55;
3
+ const BASE_DELAY_MS = 200;
4
  const MAX_DELAY_MS = 5000;
5
+ const BACKOFF_FACTOR = 1.5;
6
+ const FETCH_TIMEOUT_MS = 10000;
7
 
8
  function computeDelay(attempt, random = Math.random) {
9
  const planned = Math.min(BASE_DELAY_MS * Math.pow(BACKOFF_FACTOR, attempt - 1), MAX_DELAY_MS);
10
+ return Math.max(planned * random(), 50);
11
  }
12
 
13
  function parseRetryAfterMs(response) {
 
37
  requestHeaders = {},
38
  requestBody,
39
  method = "POST",
40
+ maxRetries: maxRetriesParam,
41
  retry = {},
42
  timeoutMs = FETCH_TIMEOUT_MS,
43
  signal,
44
  } = options;
45
 
46
+ const maxRetries = maxRetriesParam ?? retry.maxRetries ?? DEFAULT_MAX_RETRIES;
47
  const lastError = [];
48
  let currentKey = null;
49
  let currentIndex = 0;
50
 
51
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
52
+ if (signal?.aborted) {
53
+ throw new Error(`Proxy aborted after ${attempt - 1} attempts`);
54
+ }
55
+
56
  const keyInfo = getNextKey();
57
  currentKey = keyInfo.key;
58
  currentIndex = keyInfo.index;
59
 
60
  const ctrl = new AbortController();
61
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
62
+ let finalSignal = ctrl.signal;
63
+ if (signal) {
64
+ if (signal.aborted) {
65
+ clearTimeout(timer);
66
+ throw new Error(`Proxy aborted after ${attempt - 1} attempts`);
67
+ }
68
+ signal.addEventListener("abort", () => ctrl.abort(), { once: true });
69
+ }
70
 
71
  try {
72
  const response = await fetch(upstreamUrl, {
 
92
 
93
  if (isCreditExhausted(response, responseText)) {
94
  lastError.push(new Error(`HTTP ${response.status}: credits exhausted (key ${currentIndex})`));
95
+ continue;
 
 
 
96
  }
97
 
98
+ lastError.push(new Error(`HTTP ${response.status} (key ${currentIndex})`));
99
 
100
+ if (response.status === 429) {
101
+ const retryAfterMs = parseRetryAfterMs(response);
102
+ const delay = retryAfterMs !== null ? Math.max(computeDelay(attempt), retryAfterMs) : computeDelay(attempt);
103
+ await new Promise((resolve) => setTimeout(resolve, delay));
104
+ }
105
  } catch (error) {
106
  clearTimeout(timer);
 
107
 
108
  lastError.push(error instanceof Error ? error : new Error(String(error)));
109
 
110
  if (attempt === maxRetries) {
111
  throw new Error(
112
+ `Proxy failed after ${maxRetries} attempts: ${lastError.slice(-3).map((e) => e.message).join("; ")}`,
113
  );
114
  }
115
 
api/v1/chat/completions.js CHANGED
@@ -4,6 +4,8 @@ const { proxyToKimchi, streamResponse } = require("../../lib/proxy.js");
4
  const KIMCHI_UPSTREAM = "https://llm.kimchi.dev/openai/v1/chat/completions";
5
 
6
  module.exports = async function handler(req, res) {
 
 
7
  let startTime = 0;
8
  let lastKeyIndex = 0;
9
 
@@ -12,9 +14,6 @@ module.exports = async function handler(req, res) {
12
  return res.status(405).json({ error: "Method not allowed" });
13
  }
14
 
15
- const keysRaw = process.env.KIMCHI_API_KEYS;
16
- const keys = parseKeys(keysRaw);
17
-
18
  if (keys.length === 0) {
19
  return res.status(500).json({ error: "No API keys configured. Set KIMCHI_API_KEYS env var." });
20
  }
@@ -35,10 +34,10 @@ module.exports = async function handler(req, res) {
35
  let rotateIndex = keySelection.index;
36
 
37
  const getNextKey = () => {
38
- let attempts = 0;
39
- while (isKeyThrottled(keys[rotateIndex]) && attempts < keys.length) {
40
  rotateIndex = (rotateIndex + 1) % keys.length;
41
- attempts++;
42
  }
43
  const key = keys[rotateIndex];
44
  const idx = rotateIndex;
@@ -61,7 +60,8 @@ module.exports = async function handler(req, res) {
61
  requestHeaders: {
62
  "X-Request-Start": String(Date.now()),
63
  },
64
- signal: AbortSignal.timeout(50000),
 
65
  });
66
 
67
  const elapsed = Date.now() - startTime;
@@ -81,7 +81,7 @@ module.exports = async function handler(req, res) {
81
  ok: false,
82
  error: "Failed to reach Kimchi API",
83
  keyIndex: lastKeyIndex,
84
- keyTotal: keys?.length || 0,
85
  attempts: 0,
86
  elapsedMs: elapsed,
87
  details: err.message,
 
4
  const KIMCHI_UPSTREAM = "https://llm.kimchi.dev/openai/v1/chat/completions";
5
 
6
  module.exports = async function handler(req, res) {
7
+ const keysRaw = process.env.KIMCHI_API_KEYS;
8
+ const keys = parseKeys(keysRaw);
9
  let startTime = 0;
10
  let lastKeyIndex = 0;
11
 
 
14
  return res.status(405).json({ error: "Method not allowed" });
15
  }
16
 
 
 
 
17
  if (keys.length === 0) {
18
  return res.status(500).json({ error: "No API keys configured. Set KIMCHI_API_KEYS env var." });
19
  }
 
34
  let rotateIndex = keySelection.index;
35
 
36
  const getNextKey = () => {
37
+ let skipAttempts = 0;
38
+ while (isKeyThrottled(keys[rotateIndex]) && skipAttempts < keys.length) {
39
  rotateIndex = (rotateIndex + 1) % keys.length;
40
+ skipAttempts++;
41
  }
42
  const key = keys[rotateIndex];
43
  const idx = rotateIndex;
 
60
  requestHeaders: {
61
  "X-Request-Start": String(Date.now()),
62
  },
63
+ maxRetries: Math.min(keys.length, 55),
64
+ signal: AbortSignal.timeout(55000),
65
  });
66
 
67
  const elapsed = Date.now() - startTime;
 
81
  ok: false,
82
  error: "Failed to reach Kimchi API",
83
  keyIndex: lastKeyIndex,
84
+ keyTotal: keys.length,
85
  attempts: 0,
86
  elapsedMs: elapsed,
87
  details: err.message,