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
+65 -7
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,7 +81,9 @@ export class SweetisticsClient {
payload.replyToTweetId = replyToTweetId; payload.replyToTweetId = replyToTweetId;
} }
const response = await fetch(`${this.baseUrl}/api/actions/tweet`, { let response: Response;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/tweet`, {
method: 'POST', method: 'POST',
headers: { headers: {
authorization: `Bearer ${this.apiKey}`, authorization: `Bearer ${this.apiKey}`,
@@ -88,6 +92,9 @@ export class SweetisticsClient {
}, },
body: JSON.stringify(payload), 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;
try {
response = await this.fetchWithTimeout(url, {
headers: { headers: {
authorization: `Bearer ${this.apiKey}`, authorization: `Bearer ${this.apiKey}`,
...(this.userAgent ? { 'user-agent': this.userAgent } : {}), ...(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,7 +197,9 @@ export class SweetisticsClient {
], ],
}; };
const response = await fetch(`${this.baseUrl}/api/trpc/search.execute?batch=1`, { let response: Response;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/search.execute?batch=1`, {
method: 'POST', method: 'POST',
headers: { headers: {
authorization: `Bearer ${this.apiKey}`, authorization: `Bearer ${this.apiKey}`,
@@ -194,6 +208,9 @@ export class SweetisticsClient {
}, },
body: JSON.stringify({ 0: { json: payload } }), 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;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/user.getCurrent?batch=1`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ 0: { json: null } }), 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 {
response = await this.fetchWithTimeout(
`${this.baseUrl}/api/trpc/user.getCurrent?input=${encodeURIComponent('null')}`,
{
method: 'GET', method: 'GET',
headers, 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;
try {
response = await this.fetchWithTimeout(url.toString(), {
headers: { headers: {
authorization: `Bearer ${this.apiKey}`, authorization: `Bearer ${this.apiKey}`,
...(this.userAgent ? { 'user-agent': this.userAgent } : {}), ...(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);
}
} }