-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathapi.ts
More file actions
174 lines (147 loc) · 6.38 KB
/
Copy pathapi.ts
File metadata and controls
174 lines (147 loc) · 6.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
import { DRY_RUN, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
import type { VapiResponse } from "./types.ts";
// ─────────────────────────────────────────────────────────────────────────────
// Dry-run accounting
//
// In `--dry-run` mode, mutating requests (POST/PATCH/DELETE) are gated and
// counted instead of executed. The end-of-run summary in push.ts reads
// `getDryRunCounts()` to print "would create N, would update M, would delete K."
//
// GETs always run — drift detection (Stack G) and dry-run preview both need
// to fetch current platform state.
// ─────────────────────────────────────────────────────────────────────────────
const DRY_RUN_COUNTS = { POST: 0, PATCH: 0, DELETE: 0 };
export function getDryRunCounts(): { POST: number; PATCH: number; DELETE: number } {
return { ...DRY_RUN_COUNTS };
}
function formatBodyPreview(body: Record<string, unknown>): string {
// One-line preview: the first ~120 chars of the canonicalized JSON,
// truncated with an ellipsis. Helps the operator see *what* is being
// requested without dumping a multi-page payload per resource.
let preview: string;
try {
preview = JSON.stringify(body);
} catch {
preview = String(body);
}
if (preview.length > 120) preview = `${preview.slice(0, 117)}...`;
return preview;
}
// ─────────────────────────────────────────────────────────────────────────────
// HTTP Client for Vapi API
// ─────────────────────────────────────────────────────────────────────────────
export class VapiApiError extends Error {
constructor(
public readonly method: string,
public readonly endpoint: string,
public readonly statusCode: number,
public readonly apiMessage: string,
public readonly rawBody: string,
) {
super(`API ${method} ${endpoint} failed (${statusCode}): ${apiMessage}`);
this.name = "VapiApiError";
}
}
function parseApiMessage(body: string): string {
try {
const parsed = JSON.parse(body);
if (typeof parsed.message === "string") return parsed.message;
if (Array.isArray(parsed.message)) return parsed.message.join("; ");
} catch { /* not JSON, use raw body */ }
return body;
}
const MAX_RETRIES = 5;
const INITIAL_DELAY_MS = 2000;
const REQUEST_DELAY_MS = 700; // Delay between requests to avoid rate limits
let lastRequestTime = 0;
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function throttle(): Promise<void> {
const now = Date.now();
const timeSinceLastRequest = now - lastRequestTime;
if (timeSinceLastRequest < REQUEST_DELAY_MS) {
await sleep(REQUEST_DELAY_MS - timeSinceLastRequest);
}
lastRequestTime = Date.now();
}
// 429 = rate limit. 5xx = transient server error (gateway timeout, upstream
// hiccup, deploy in progress). Both are worth retrying with backoff; surfacing
// a 502 as a hard failure forces the operator to re-run the entire push.
function shouldRetry(status: number): boolean {
return status === 429 || (status >= 500 && status < 600);
}
export async function vapiRequest<T = VapiResponse>(
method: "POST" | "PATCH",
endpoint: string,
body: Record<string, unknown>
): Promise<T> {
const url = `${VAPI_BASE_URL}${endpoint}`;
if (DRY_RUN) {
DRY_RUN_COUNTS[method]++;
console.log(
` 🧪 [dry-run] would ${method} ${endpoint} ${formatBodyPreview(body)}`,
);
// Returning a stable fake response shaped like a typical create response.
// For PATCH the engine ignores the return (other than for `.id`); for
// POST the engine writes the returned id into state. In dry-run the
// state file is never persisted, so the synthetic id is local-only.
return {
id: `dry-run-${method.toLowerCase()}-${Date.now()}`,
} as unknown as T;
}
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
await throttle();
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${VAPI_TOKEN}`,
},
body: JSON.stringify(body),
});
if (response.ok) {
return response.json() as Promise<T>;
}
if (shouldRetry(response.status) && attempt < MAX_RETRIES) {
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt);
const reason = response.status === 429 ? "Rate limited" : `Server error ${response.status}`;
console.log(` ⏳ ${reason}, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})...`);
await sleep(delay);
continue;
}
const errorText = await response.text();
throw new VapiApiError(method, endpoint, response.status, parseApiMessage(errorText), errorText);
}
throw new VapiApiError(method, endpoint, 429, "max retries exceeded", "");
}
export async function vapiDelete(endpoint: string): Promise<void> {
const url = `${VAPI_BASE_URL}${endpoint}`;
if (DRY_RUN) {
DRY_RUN_COUNTS.DELETE++;
console.log(` 🧪 [dry-run] would DELETE ${endpoint}`);
return;
}
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
await throttle();
const response = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
},
});
if (response.ok) {
return;
}
if (shouldRetry(response.status) && attempt < MAX_RETRIES) {
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt);
const reason = response.status === 429 ? "Rate limited" : `Server error ${response.status}`;
console.log(` ⏳ ${reason}, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})...`);
await sleep(delay);
continue;
}
const errorText = await response.text();
throw new VapiApiError("DELETE", endpoint, response.status, parseApiMessage(errorText), errorText);
}
throw new VapiApiError("DELETE", endpoint, 429, "max retries exceeded", "");
}