Skip to content

Commit 958b037

Browse files
charliecreates[bot]CharlieHelpsmergify[bot]
authored
feat: add timeout, retry, and rate-limit resilience to Hevy API client (#425)
* fix: add timeout and bounded retries for hevy api client * test: cover Hevy client retry edge cases * fix: bound retry-after delays and retry-exhausted classification * style: format hevy client kubb tests * test: cover retry-aware error messages --------- Co-authored-by: CharlieHelps <charlie@charlielabs.ai> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent a708b3c commit 958b037

7 files changed

Lines changed: 1136 additions & 83 deletions

File tree

.changeset/curly-pears-smile.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"hevy-mcp": patch
3+
---
4+
5+
Add resilient Hevy API request handling with configurable timeout,
6+
bounded retries for transient GET failures, Retry-After support for
7+
429 responses, and clearer user-facing rate-limit/transient error
8+
messages.

.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
HEVY_API_KEY=HEVY_KEY
2+
# Optional timeout for Hevy API requests in milliseconds (default: 30000)
3+
# HEVY_MCP_API_TIMEOUT=30000
24

35
# Sentry Configuration (optional - for source map uploads during build)
46
# SENTRY_AUTH_TOKEN=your_sentry_auth_token_here

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,14 @@ Supply your Hevy API key via the `HEVY_API_KEY` environment variable (in
175175
> `hevy-api-key=...`) are still accepted for backward compatibility, but are
176176
> deprecated and insecure. Use `HEVY_API_KEY` instead.
177177
178+
Set `HEVY_MCP_API_TIMEOUT` to override the default 30-second Hevy API request
179+
timeout. Its value is in milliseconds.
180+
178181
```env
179182
# Example .env
180183
HEVY_API_KEY=your_hevy_api_key_here
184+
# Optional: customize Hevy API request timeout (milliseconds)
185+
HEVY_MCP_API_TIMEOUT=30000
181186
```
182187

183188
### 🧠 Exercise Template Cache Behavior

src/utils/error-handler.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,8 @@ describe("Error Handler", () => {
227227
},
228228
{
229229
status: 429,
230-
expectedMessage: "Rate limited by Hevy. Please wait and retry.",
230+
expectedMessage:
231+
"Rate limited by Hevy (HTTP 429). Please wait and retry your request.",
231232
},
232233
{
233234
status: 503,
@@ -264,6 +265,97 @@ describe("Error Handler", () => {
264265
},
265266
);
266267

268+
it("includes Retry-After in 429 error messages", () => {
269+
const response = createErrorResponse({
270+
isAxiosError: true,
271+
response: {
272+
headers: { "retry-after": "12" },
273+
status: 429,
274+
},
275+
});
276+
277+
expect(response.content[0].text).toBe(
278+
"Error: Rate limited by Hevy (HTTP 429). " +
279+
"Please wait about 12 seconds before retrying.",
280+
);
281+
});
282+
283+
it("reads Retry-After from header getters with array values", () => {
284+
const response = createErrorResponse({
285+
isAxiosError: true,
286+
response: {
287+
headers: { get: () => [4] },
288+
status: 429,
289+
},
290+
});
291+
292+
expect(response.content[0].text).toBe(
293+
"Error: Rate limited by Hevy (HTTP 429). " +
294+
"Please wait about 4 seconds before retrying.",
295+
);
296+
});
297+
298+
it("includes HTTP-date Retry-After values in 429 error messages", () => {
299+
const now = Date.parse("2026-07-09T19:40:00Z");
300+
vi.spyOn(Date, "now").mockReturnValue(now);
301+
const response = createErrorResponse({
302+
isAxiosError: true,
303+
response: {
304+
headers: {
305+
"retry-after": new Date(now + 2_000).toUTCString(),
306+
},
307+
status: 429,
308+
},
309+
});
310+
311+
expect(response.content[0].text).toBe(
312+
"Error: Rate limited by Hevy (HTTP 429). " +
313+
"Please wait about 2 seconds before retrying.",
314+
);
315+
});
316+
317+
it("falls back to the generic message for invalid Retry-After values", () => {
318+
const response = createErrorResponse({
319+
isAxiosError: true,
320+
response: {
321+
headers: { "retry-after": "not-a-date" },
322+
status: 429,
323+
},
324+
});
325+
326+
expect(response.content[0].text).toBe(
327+
"Error: Rate limited by Hevy (HTTP 429). " +
328+
"Please wait and retry your request.",
329+
);
330+
});
331+
332+
it("prioritizes exhausted retry errors over status mappings", () => {
333+
const response = createErrorResponse({
334+
hevyRetryCount: 2,
335+
hevyRetryExhausted: true,
336+
isAxiosError: true,
337+
response: { status: 503 },
338+
});
339+
340+
expect(response.content[0].text).toBe(
341+
"Error: Unable to complete the request after 3 attempts " +
342+
"to the Hevy API due to transient failures. Please try again shortly.",
343+
);
344+
});
345+
346+
it("describes exhausted retry errors without a retry count", () => {
347+
const response = createErrorResponse({
348+
hevyRetryExhausted: true,
349+
isAxiosError: true,
350+
response: { status: 503 },
351+
});
352+
353+
expect(response.content[0].text).toBe(
354+
"Error: Unable to complete the request after multiple attempts " +
355+
"to the Hevy API due to transient failures. Please try again shortly.",
356+
);
357+
});
358+
267359
it("uses raw axios string data when no Hevy status mapping exists", () => {
268360
const response = createErrorResponse(
269361
createMockAxiosError(400, "plain upstream message"),

src/utils/error-handler.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,130 @@ export interface ErrorResponse {
2222
*/
2323
export enum ErrorType {
2424
API_ERROR = "API_ERROR",
25+
RATE_LIMIT = "RATE_LIMIT",
2526
VALIDATION_ERROR = "VALIDATION_ERROR",
2627
NOT_FOUND = "NOT_FOUND",
2728
NETWORK_ERROR = "NETWORK_ERROR",
2829
UNKNOWN_ERROR = "UNKNOWN_ERROR",
2930
}
3031

32+
type RetryAwareError = {
33+
hevyRetryCount?: number;
34+
hevyRetryExhausted?: boolean;
35+
};
36+
37+
function normalizeHeaderValue(value: unknown): string | undefined {
38+
if (typeof value === "string") {
39+
const trimmed = value.trim();
40+
return trimmed.length > 0 ? trimmed : undefined;
41+
}
42+
43+
if (typeof value === "number" && Number.isFinite(value)) {
44+
return String(value);
45+
}
46+
47+
if (Array.isArray(value) && value.length > 0) {
48+
return normalizeHeaderValue(value[0]);
49+
}
50+
51+
return undefined;
52+
}
53+
54+
function getHeaderValue(headers: unknown, key: string): string | undefined {
55+
if (!headers || typeof headers !== "object") {
56+
return undefined;
57+
}
58+
59+
if (
60+
"get" in headers &&
61+
typeof (headers as { get?: unknown }).get === "function"
62+
) {
63+
const value = (headers as { get: (headerName: string) => unknown }).get(
64+
key,
65+
);
66+
return normalizeHeaderValue(value);
67+
}
68+
69+
const headerRecord = headers as Record<string, unknown>;
70+
return normalizeHeaderValue(
71+
headerRecord[key] ??
72+
headerRecord[key.toLowerCase()] ??
73+
headerRecord[key.toUpperCase()],
74+
);
75+
}
76+
77+
function formatSecondsLabel(seconds: number): string {
78+
const roundedSeconds = Math.max(0, Math.round(seconds));
79+
const suffix = roundedSeconds === 1 ? "" : "s";
80+
return `${roundedSeconds} second${suffix}`;
81+
}
82+
83+
function getRateLimitMessage(error: unknown): string {
84+
if (!isAxiosError(error)) {
85+
return "Rate limited by Hevy. Please wait and retry.";
86+
}
87+
88+
const retryAfterHeader = getHeaderValue(
89+
error.response?.headers,
90+
"retry-after",
91+
);
92+
if (!retryAfterHeader) {
93+
return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
94+
}
95+
96+
const seconds = Number(retryAfterHeader);
97+
if (Number.isFinite(seconds) && seconds >= 0) {
98+
return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(seconds)} before retrying.`;
99+
}
100+
101+
const retryAtMillis = Date.parse(retryAfterHeader);
102+
if (!Number.isNaN(retryAtMillis)) {
103+
const secondsUntilRetry = Math.ceil(
104+
Math.max(0, retryAtMillis - Date.now()) / 1000,
105+
);
106+
return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(secondsUntilRetry)} before retrying.`;
107+
}
108+
109+
return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
110+
}
111+
112+
function isRetryExhaustedError(error: unknown): boolean {
113+
return (
114+
!!error &&
115+
typeof error === "object" &&
116+
(error as RetryAwareError).hevyRetryExhausted === true
117+
);
118+
}
119+
120+
function getRetryExhaustedMessage(error: unknown): string {
121+
const retryCount =
122+
typeof error === "object" && error !== null
123+
? (error as RetryAwareError).hevyRetryCount
124+
: undefined;
125+
const attemptCount =
126+
typeof retryCount === "number" && Number.isFinite(retryCount)
127+
? retryCount + 1
128+
: undefined;
129+
130+
if (attemptCount) {
131+
return `Unable to complete the request after ${attemptCount} attempts to the Hevy API due to transient failures. Please try again shortly.`;
132+
}
133+
134+
return "Unable to complete the request after multiple attempts to the Hevy API due to transient failures. Please try again shortly.";
135+
}
136+
137+
function getUserFacingMessage(error: unknown, defaultMessage: string): string {
138+
if (isRetryExhaustedError(error)) {
139+
return getRetryExhaustedMessage(error);
140+
}
141+
142+
if (isAxiosError(error) && error.response?.status === 429) {
143+
return getRateLimitMessage(error);
144+
}
145+
146+
return defaultMessage;
147+
}
148+
31149
/**
32150
* Enhanced error response with type categorization
33151
*/
@@ -79,6 +197,8 @@ export function createErrorResponse(
79197
errorMessage = stringifyErrorData(axiosErrorContext.data);
80198
}
81199

200+
errorMessage = getUserFacingMessage(error, errorMessage);
201+
82202
// Extract error code if available (for logging purposes)
83203
const errorCode =
84204
error instanceof Error && "code" in error
@@ -210,6 +330,14 @@ function stringifyErrorData(data: unknown): string {
210330
* Determine the type of error based on error characteristics
211331
*/
212332
function determineErrorType(error: unknown, message: string): ErrorType {
333+
if (isRetryExhaustedError(error)) {
334+
return ErrorType.NETWORK_ERROR;
335+
}
336+
337+
if (isAxiosError(error) && error.response?.status === 429) {
338+
return ErrorType.RATE_LIMIT;
339+
}
340+
213341
const messageLower = message.toLowerCase();
214342
const nameLower = error instanceof Error ? error.name.toLowerCase() : "";
215343

0 commit comments

Comments
 (0)