Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- summary: |
Detect Node.js undici timeout errors (UND_ERR_HEADERS_TIMEOUT, UND_ERR_BODY_TIMEOUT)
and classify them as `reason: "timeout"` instead of `reason: "unknown"`. This fixes
an issue where Node's built-in fetch has a hard 300s headersTimeout that fires before
the SDK's timeoutInSeconds, causing generic "fetch failed" errors.
type: fix

- summary: |
Include the full error cause chain in unknown error messages so underlying error codes
(e.g., undici error codes, ECONNRESET) surface to users instead of just "fetch failed".
type: fix

- summary: |
Retry on network-level failures (e.g., connection resets, socket errors) in addition
to retryable HTTP status codes (408, 429, 5xx). Previously, thrown errors from the
fetch call bypassed retry logic entirely.
type: feat
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,33 @@ async function getHeaders(args: Fetcher.Args): Promise<Headers> {
return newHeaders;
}

const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "ETIMEDOUT"]);

function isTimeoutError(error: Error): boolean {
if ("cause" in error && error.cause instanceof Error && "code" in error.cause) {
const { code } = error.cause as Error & { code: unknown };
if (typeof code === "string" && TIMEOUT_ERROR_CODES.has(code)) {
return true;
}
}
return false;
}

function getErrorMessageWithCause(error: Error): string {
let message = error.message;
let current: unknown = "cause" in error ? error.cause : undefined;
while (current instanceof Error) {
const suffix = "code" in current && typeof (current as Error & { code: unknown }).code === "string"
? ` [${(current as Error & { code: string }).code}]`
: "";
if (current.message) {
message += ` -> ${current.message}${suffix}`;
}
current = "cause" in current ? current.cause : undefined;
}
return message;
}

export async function fetcherImpl<R = unknown>(args: Fetcher.Args): Promise<APIResponse<R, Fetcher.Error>> {
let url = args.url;
if (args.queryString != null && args.queryString.length > 0) {
Expand Down Expand Up @@ -370,20 +397,38 @@ export async function fetcherImpl<R = unknown>(args: Fetcher.Args): Promise<APIR
},
rawResponse: abortRawResponse,
};
} else if (error instanceof Error && isTimeoutError(error)) {
if (logger.isError()) {
const metadata = {
method: args.method,
url: redactUrl(url),
timeoutMs: args.timeoutMs,
errorMessage: getErrorMessageWithCause(error),
};
logger.error("HTTP request timed out due to runtime timeout", metadata);
}
return {
ok: false,
error: {
reason: "timeout",
cause: error,
},
rawResponse: unknownRawResponse,
};
} else if (error instanceof Error) {
if (logger.isError()) {
const metadata = {
method: args.method,
url: redactUrl(url),
errorMessage: error.message,
errorMessage: getErrorMessageWithCause(error),
};
logger.error("HTTP request failed with error", metadata);
}
return {
ok: false,
error: {
reason: "unknown",
errorMessage: error.message,
errorMessage: getErrorMessageWithCause(error),
cause: error,
},
rawResponse: unknownRawResponse,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ function addSymmetricJitter(delay: number): number {
return delay * jitterMultiplier;
}

const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "ETIMEDOUT"]);

function isRetryableError(error: unknown): boolean {
if (!(error instanceof Error)) {
return true;
}
if (error.name === "AbortError") {
return false;
}
if ("cause" in error && error.cause instanceof Error && "code" in error.cause) {
const { code } = error.cause as Error & { code: unknown };
if (typeof code === "string" && TIMEOUT_ERROR_CODES.has(code)) {
return false;
}
}
return true;
}

function getRetryDelayFromHeaders(response: Response, retryAttempt: number): number {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
Expand Down Expand Up @@ -48,17 +66,40 @@ export async function requestWithRetries(
requestFn: () => Promise<Response>,
maxRetries: number = DEFAULT_MAX_RETRIES,
): Promise<Response> {
let response: Response = await requestFn();
let lastError: unknown;
let lastResponse: Response | undefined;

for (let i = 0; i <= maxRetries; i++) {
try {
const response = await requestFn();
lastResponse = response;
lastError = undefined;

if (!([408, 429].includes(response.status) || response.status >= 500)) {
return response;
}

if (i === maxRetries) {
return response;
}

for (let i = 0; i < maxRetries; ++i) {
if ([408, 429].includes(response.status) || response.status >= 500) {
const delay = getRetryDelayFromHeaders(response, i);
await new Promise((resolve) => setTimeout(resolve, delay));
} catch (error) {
lastError = error;
lastResponse = undefined;

if (i === maxRetries || !isRetryableError(error)) {
throw error;
}

const delay = addSymmetricJitter(Math.min(INITIAL_RETRY_DELAY * 2 ** i, MAX_RETRY_DELAY));
await new Promise((resolve) => setTimeout(resolve, delay));
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
response = await requestFn();
} else {
break;
}
}
return response!;

if (lastResponse != null) {
return lastResponse;
}
throw lastError;
}
49 changes: 47 additions & 2 deletions seed/ts-sdk/accept-header/src/core/fetcher/Fetcher.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 47 additions & 2 deletions seed/ts-sdk/alias-extends/src/core/fetcher/Fetcher.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading