perf: Add timeout and optimize URL unfurl performance (#11149)

* perf: Add timeout and optimize URL unfurl performance

Fixes issue where urls.unfurl endpoint could take 15+ seconds due to external API timeouts and sequential plugin execution.

Changes:
- Add timeout support to fetch utility with AbortController (defaults to no timeout, configurable per request)
- Add 10 second timeout to Iframely plugin requests
- Add early URL pattern validation to GitHub and Linear plugins to avoid unnecessary database queries
- Add try-catch error handling to URL parsing in GitHub and Linear plugins

This reduces worst-case unfurl time from 15+ seconds to ~10 seconds maximum, and eliminates unnecessary overhead for URLs that don't match plugin patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* lint

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-01-10 18:03:19 -05:00
committed by GitHub
parent 23d4374cb0
commit bcee4893f4
4 changed files with 67 additions and 32 deletions
+22 -16
View File
@@ -140,24 +140,29 @@ export class GitHub {
* @returns {object} Containing resource identifiers - `owner`, `repo`, `type` and `id`.
*/
public static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "github.com") {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== "github.com") {
return;
}
const parts = pathname.split("/");
const owner = parts[1];
const repo = parts[2];
const type = parts[3]
? (pluralize.singular(parts[3]) as UnfurlResourceType)
: undefined;
const id = Number(parts[4]);
if (!type || !GitHub.supportedResources.includes(type)) {
return;
}
return { owner, repo, type, id, url };
} catch (_err) {
// Invalid URL format
return;
}
const parts = pathname.split("/");
const owner = parts[1];
const repo = parts[2];
const type = parts[3]
? (pluralize.singular(parts[3]) as UnfurlResourceType)
: undefined;
const id = Number(parts[4]);
if (!type || !GitHub.supportedResources.includes(type)) {
return;
}
return { owner, repo, type, id, url };
}
private static authenticateAsApp = () => {
@@ -222,6 +227,7 @@ export class GitHub {
* @returns An object containing resource details e.g, a GitHub Pull Request details
*/
public static unfurl: UnfurlSignature = async (url: string, actor: User) => {
// Early return if URL doesn't match GitHub pattern (before any DB queries)
const resource = GitHub.parseUrl(url);
if (!resource) {
+4 -1
View File
@@ -21,7 +21,10 @@ class Iframely {
const res = await fetch(
`${apiUrl}/${type}?url=${encodeURIComponent(url)}&api_key=${
env.IFRAMELY_API_KEY
}`
}`,
{
timeout: 10000, // 10 second timeout
}
);
return await res.json();
} catch (err) {
+20 -14
View File
@@ -105,6 +105,7 @@ export class Linear {
* @returns An object containing resource details e.g, a Linear issue details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
// Early return if URL doesn't match Linear pattern (before any DB queries)
const resource = Linear.parseUrl(url);
if (!resource) {
@@ -241,21 +242,26 @@ export class Linear {
* @returns An object containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "linear.app") {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== "linear.app") {
return;
}
const parts = pathname.split("/");
const workspaceKey = parts[1];
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
const id = parts[3];
const name = parts[4];
if (!type || !Linear.supportedUnfurls.includes(type)) {
return;
}
return { workspaceKey, type, id, name };
} catch (_err) {
// Invalid URL format
return;
}
const parts = pathname.split("/");
const workspaceKey = parts[1];
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
const id = parts[3];
const name = parts[4];
if (!type || !Linear.supportedUnfurls.includes(type)) {
return;
}
return { workspaceKey, type, id, name };
}
}
+21 -1
View File
@@ -52,11 +52,23 @@ export default async function fetch(
url: string,
init?: RequestInit & {
allowPrivateIPAddress?: boolean;
timeout?: number;
}
): Promise<Response> {
Logger.silly("http", `Network request to ${url}`, init);
const { allowPrivateIPAddress, ...rest } = init || {};
const { allowPrivateIPAddress, timeout, ...rest } = init || {};
// Create AbortController for timeout if specified
let abortController: AbortController | undefined;
let timeoutId: NodeJS.Timeout | undefined;
if (timeout && !rest.signal) {
abortController = new AbortController();
timeoutId = setTimeout(() => {
abortController?.abort();
}, timeout);
}
try {
const response = await nodeFetch(url, {
@@ -65,6 +77,7 @@ export default async function fetch(
"User-Agent": outlineUserAgent,
...rest?.headers,
},
signal: abortController?.signal || rest.signal,
agent: buildAgent(url, init),
});
@@ -79,12 +92,19 @@ export default async function fetch(
return response;
} catch (err) {
if (err.name === "AbortError") {
throw new Error(`Request timeout after ${timeout}ms`);
}
if (!env.isCloudHosted && err.message?.startsWith("DNS lookup")) {
throw InternalError(
`${err.message}\n\nTo allow this request, add the IP address or CIDR range to the ALLOWED_PRIVATE_IP_ADDRESSES environment variable.`
);
}
throw err;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}