tonhowtf/omniget · warning · Error

ERR_TOO_MANY_ATTACHMENTS

ERR_TOO_MANY_ATTACHMENTS

Error message

ERR_TOO_MANY_ATTACHMENTS:${limits.maxAttachments}

What it means

Raised by extract_graphql_media when Twitter's GraphQL response contains a tombstone entry whose reason is "NsfwLoggedOut" or whose tombstone text starts with "Age-restricted". The tweet exists but is age-gated, and the logged-out/unauthenticated session used for scraping is not allowed to see it, so the extractor aborts early with this message instead of trying to parse media that isn't there.

Solutions

  1. Authenticate the Twitter session (valid auth_token/ct0 cookies of an age-verified account) before fetching the tweet
  2. Mark the post as age-restricted/skippable in the caller instead of treating it as a hard failure
  3. Try the syndication API fallback (extract_syndication_media), which sometimes serves media for sensitive tweets without auth
  4. Retry with a different account/session if the current one is not age-verified

Example fix

// before
if reason == "NsfwLoggedOut" || tombstone_text.starts_with("Age-restricted") {
    return Err(anyhow!("Age-restricted content"));
}
// after
if reason == "NsfwLoggedOut" || tombstone_text.starts_with("Age-restricted") {
    return Err(TwitterError::AgeRestricted { tweet_id }); // typed error callers can match on and skip
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Before fetching, ensure the session is authenticated with an age-verified account
if !session.has_auth_cookies() {
    return Err("age-gated tweets require an authenticated, age-verified session");
}

Type guard

function isAgeRestrictedTombstone(result) {
  return result?.__typename === 'TweetTombstone' &&
    (result?.tombstone?.reason === 'NsfwLoggedOut' ||
     String(result?.tombstone?.text ?? '').startsWith('Age-restricted'));
}

Try / catch

match tweet.extract_graphql_media(id) {
    Err(e) if e.to_string() == "Age-restricted content" => skip(id, Reason::AgeRestricted),
    Err(e) => return Err(e),
    Ok(media) => Ok(media),
}

Prevention

When it happens

Trigger: Calling extract_graphql_media (via the Twitter GraphQL tweet-detail flow) on a tweet_id whose result is a TweetTombstone with reason == "NsfwLoggedOut", or whose tombstone text begins with "Age-restricted" — i.e. the API returns a tombstone object instead of a "Tweet"/"TweetWithVisibilityResults" typename.

Common situations: Scraping NSFW/sensitive-media tweets while logged out; an account cookie or auth_token that expired so the session degrades to logged-out; tweets flagged sensitive by the author (sensitive_content flag) that require an age-verified logged-in session; region/age restrictions on the calling IP.

Related errors


AI-assisted analysis of tonhowtf/omniget@8600b91f42 (2026-09-12). Data as JSON: /api/errors/7e4b26df2025bb50. Report an issue: GitHub.

Appendix: source

Thrown at src/lib/stores/omnidisc-store.svelte.ts:2577

function clearUploads(channelId: string) {
  for (const upload of uploadsByChannel[channelId] ?? []) {
    if (upload.previewUrl) URL.revokeObjectURL(upload.previewUrl);
  }
  const next = { ...uploadsByChannel };
  delete next[channelId];
  uploadsByChannel = next;
}

export async function attachFile(
  channelId: string,
  file: { path: string; name?: string; previewUrl?: string },
): Promise<string | null> {
  const instance = instanceForChannel(channelId);
  if (!instance || isDemo(instance.id)) return null;
  const limits = await getUploadLimits(instance.id);
  const already = uploadsByChannel[channelId] ?? [];
  if (already.length >= limits.maxAttachments) {
    throw new Error(`ERR_TOO_MANY_ATTACHMENTS:${limits.maxAttachments}`);
  }
  const encrypted = isEncryptedChannel(channelId);
  // Rust owns the size check: it reads the file anyway and knows the instance
  // limit, so the answer arrives before the first byte moves.
  const started = await invoke<{ id: string; size: number; name: string }>(
    "omnidisc_upload_start",
    { url: instance.url, channelId, path: file.path, encrypt: encrypted },
  );
  putUpload({
    id: started.id,
    channelId,
    name: file.name ?? started.name,
    path: file.path,
    sent: 0,
    total: started.size,
    state: "preparing",
    encrypted,
    previewUrl: file.previewUrl,

View on GitHub (pinned to 8600b91f42)