Add Sweetistics request timeout and docs
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user