Add Sweetistics request timeout and docs

This commit is contained in:
Peter Steinberger
2025-12-04 14:06:57 +00:00
parent dfe71082c6
commit 35c0f28997
3 changed files with 99 additions and 39 deletions
+1
View File
@@ -18,3 +18,4 @@
### Fixed ### Fixed
- Fallback to scraping the authenticated settings page when Twitter account APIs return 404, so `whoami` still resolves the user. - Fallback to scraping the authenticated settings page when Twitter account APIs return 404, so `whoami` still resolves the user.
- Sweetistics calls now time out after 15s to avoid hanging CLI commands when the API is slow or unresponsive.
+1
View File
@@ -175,6 +175,7 @@ bird --sweetistics-base-url "http://localhost:3000" --sweetistics-api-key "sweet
``` ```
When an API key is present, `bird` will use Sweetistics’ `/api/actions/tweet` endpoint and skip local cookie resolution. When an API key is present, `bird` will use Sweetistics’ `/api/actions/tweet` endpoint and skip local cookie resolution.
All Sweetistics calls have a 15s timeout so the CLI won’t hang if the API is slow or unreachable.
### Getting Your Cookies ### Getting Your Cookies
+97 -39
View File
@@ -63,6 +63,8 @@ export class SweetisticsClient {
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly apiKey: string; private readonly apiKey: string;
private readonly userAgent?: string; private readonly userAgent?: string;
// Prevent hanging requests; keep Sweetistics calls snappy for CLI users.
private static readonly REQUEST_TIMEOUT_MS = 15_000;
constructor(options: SweetisticsClientOptions) { constructor(options: SweetisticsClientOptions) {
this.baseUrl = normalizeBaseUrl(options.baseUrl); this.baseUrl = normalizeBaseUrl(options.baseUrl);
@@ -79,15 +81,20 @@ export class SweetisticsClient {
payload.replyToTweetId = replyToTweetId; payload.replyToTweetId = replyToTweetId;
} }
const response = await fetch(`${this.baseUrl}/api/actions/tweet`, { let response: Response;
method: 'POST', try {
headers: { response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/tweet`, {
authorization: `Bearer ${this.apiKey}`, method: 'POST',
'content-type': 'application/json', headers: {
...(this.userAgent ? { 'user-agent': this.userAgent } : {}), authorization: `Bearer ${this.apiKey}`,
}, 'content-type': 'application/json',
body: JSON.stringify(payload), ...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
}); },
body: JSON.stringify(payload),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
let data: unknown; let data: unknown;
try { try {
@@ -117,12 +124,17 @@ export class SweetisticsClient {
async read(tweetId: string): Promise<SweetisticsReadResult> { async read(tweetId: string): Promise<SweetisticsReadResult> {
// Public REST route that returns a single tweet record // Public REST route that returns a single tweet record
const url = `${this.baseUrl}/api/tweets/${encodeURIComponent(tweetId)}`; const url = `${this.baseUrl}/api/tweets/${encodeURIComponent(tweetId)}`;
const response = await fetch(url, { let response: Response;
headers: { try {
authorization: `Bearer ${this.apiKey}`, response = await this.fetchWithTimeout(url, {
...(this.userAgent ? { 'user-agent': this.userAgent } : {}), headers: {
}, authorization: `Bearer ${this.apiKey}`,
}); ...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) { if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` }; return { success: false, error: `HTTP ${response.status}` };
@@ -185,15 +197,20 @@ export class SweetisticsClient {
], ],
}; };
const response = await fetch(`${this.baseUrl}/api/trpc/search.execute?batch=1`, { let response: Response;
method: 'POST', try {
headers: { response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/search.execute?batch=1`, {
authorization: `Bearer ${this.apiKey}`, method: 'POST',
'content-type': 'application/json', headers: {
...(this.userAgent ? { 'user-agent': this.userAgent } : {}), authorization: `Bearer ${this.apiKey}`,
}, 'content-type': 'application/json',
body: JSON.stringify({ 0: { json: payload } }), ...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
}); },
body: JSON.stringify({ 0: { json: payload } }),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) { if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` }; return { success: false, error: `HTTP ${response.status}` };
@@ -271,18 +288,30 @@ export class SweetisticsClient {
...(this.userAgent ? { 'user-agent': this.userAgent } : {}), ...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
} satisfies Record<string, string>; } satisfies Record<string, string>;
let response = await fetch(`${this.baseUrl}/api/trpc/user.getCurrent?batch=1`, { let response: Response;
method: 'POST', try {
headers, response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/user.getCurrent?batch=1`, {
body: JSON.stringify({ 0: { json: null } }), method: 'POST',
}); headers,
body: JSON.stringify({ 0: { json: null } }),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
// Some deployments only allow GET for queries; fall back if POST is not allowed // Some deployments only allow GET for queries; fall back if POST is not allowed
if (response.status === 405) { if (response.status === 405) {
response = await fetch(`${this.baseUrl}/api/trpc/user.getCurrent?input=${encodeURIComponent('null')}`, { try {
method: 'GET', response = await this.fetchWithTimeout(
headers, `${this.baseUrl}/api/trpc/user.getCurrent?input=${encodeURIComponent('null')}`,
}); {
method: 'GET',
headers,
}
);
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
} }
if (!response.ok) { if (!response.ok) {
@@ -353,12 +382,17 @@ export class SweetisticsClient {
const url = new URL(`${this.baseUrl}/api/trpc/tweets.getConversation`); const url = new URL(`${this.baseUrl}/api/trpc/tweets.getConversation`);
url.searchParams.set('input', JSON.stringify({ tweetId })); url.searchParams.set('input', JSON.stringify({ tweetId }));
const response = await fetch(url.toString(), { let response: Response;
headers: { try {
authorization: `Bearer ${this.apiKey}`, response = await this.fetchWithTimeout(url.toString(), {
...(this.userAgent ? { 'user-agent': this.userAgent } : {}), headers: {
}, authorization: `Bearer ${this.apiKey}`,
}); ...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) { if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` }; return { success: false, error: `HTTP ${response.status}` };
@@ -423,4 +457,28 @@ export class SweetisticsClient {
return { success: true, tweets }; return { success: true, tweets };
} }
private async fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(new Error(`Request timed out after ${SweetisticsClient.REQUEST_TIMEOUT_MS}ms`)),
SweetisticsClient.REQUEST_TIMEOUT_MS,
);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
private normalizeError(error: unknown): string {
if (error instanceof Error) {
if ((error as { name?: string }).name === 'AbortError') {
return 'Request timed out';
}
return error.message;
}
return String(error);
}
} }