跳转至

集成方运维须知

面向把本项目接进自己调度系统的调用方。这里只放接入后必须知道、否则会踩坑的内容: 失败怎么分类、状态能不能跨版本用、容器怎么探活、账号该按什么节奏用。

接口清单见接口参考与接口验证状态,本页不重复。


一、失败原因分类(必读)

为什么 gRPC 状态码不够

调用方对下列情况的处置完全不同,但它们可能落在同一个 gRPC 码上:

实际情况 正确处置 处置错了会怎样
账号/IP 被临时限流 退避后重试,不动账号状态 当成封号剔除 → 好号被永久丢弃
会话失效、账号正常 重新登录 当成封号剔除 → 同上
账号被永久封禁 标记并剔出选号池 当成会话失效 → 反复重登,烧登录配额、加重风控
账号锁定 / 待人机验证 人工介入 自动重试无法恢复,任务批量失败

所以每个上游失败都会通过标准 gRPC rich status 携带机器可读的 FailureDetail。它把账号处置原因、 当前请求的重试建议和传输层事实拆开,避免把“上游暂时不可用”误读成“写请求可以直接重放”。

这里的 TransportFailure 只描述 Go server → X/出口代理 的出站请求。若 TLS EOF 发生在 调用方 → gRPC server 的入站握手阶段,RPC 尚未建立,服务端不可能返回 status details;此时 TwitterAPIError.failure 会保持默认值,应检查 gRPC 入口证书、SNI、负载均衡器和连接日志。

怎么读

from twitter_sdk import FailureKind, RetryAdvice, TwitterAPIError

try:
    state = await client.login(username=U, password=P, totp_seed=S)
except TwitterAPIError as error:
    kind = error.failure.kind
    advice = error.failure.retry_advice
    if kind == FailureKind.FAILURE_KIND_RATE_LIMITED:
        ...
    if advice == RetryAdvice.RETRY_ADVICE_VERIFY_EFFECT_THEN_RETRY:
        ...  # 写请求可能已经生效,先查证

TwitterAPIError.message 是脱敏后的人类可读摘要,只能展示,禁止据此做程序分支。调用方不使用 Python SDK 时,应按 gRPC 标准 rich error model 解码 google.rpc.Status.details[] 中的 twitter.common.v1.FailureDetail,不得自行发明或解析错误字符串协议。

登录失败:定位到具体步骤

登录是一条跨多个上游端点的状态机,「登录失败了」本身不可操作。失败时,服务端会在另一个标准 status detail twitter.android.v1.AndroidFailureContext 中附带 login_steps,逐步给出 name / ok / failure_kind / failure_summary / http_status:

for s in error.failure.login_steps:
    print(s.name, s.ok, s.http_status)
# app bearer        True  0
# attest bootstrap  True  0
# guest activate    True  0
# onboarding warm   True  0
# username step     False 0     ← 失败在这里

error.failure.state 是失败时的状态检查点。Login 失败时调用方应像保存成功 state 一样安全持久化它, 避免重试时重新生成设备;account-bound 调用则会在抛出 TwitterAPIError 前自动同步 account.state。该字段含密码、OAuth secret 和代理凭据,禁止写入日志。

步骤名是固定标签(app bearer / attest bootstrap / guest activate / onboarding warm / username step / password step / 2fa step / attest rebind / whoami confirmation),可以直接用于聚合统计。

failure_summary 是由 failure_kind 选出的固定文案,不含上游响应正文—— 它不会携带 token、cookie 或账号资料,可以安全写进你的日志系统。 排查用途之外不要解析它,要分支请用 failure_kind。

分类取值

Kind 含义 建议处置
RATE_LIMITED 账号/IP 被 X 临时限流;凭据与设备证明均正常 退避重试。禁止改账号状态
SESSION_EXPIRED 会话不再被接受,账号本身正常 重新登录
RISK_BLOCKED 命中风控 /「请使用官方 App」闸门 换设备/代理再试;不是凭据问题
CREDENTIAL_INVALID 用户名或密码被拒(含密码在别处被改) 人工介入。重试会烧登录配额
TWO_FACTOR_REQUIRED 需要 2FA 码但未提供或未被接受 改传 totp_seed(见下)
DEVICE_PROOF_MISSING 设备证明前置条件不满足,请求未发出 服务端配置问题,别盲重试
LOCKED_NEEDS_VERIFICATION 账号锁定 / 待人机验证 人工介入
BANNED 账号被永久封禁 剔出选号池(先读下方警告)
UPSTREAM_UNAVAILABLE 上游 5xx / 超时 / 连接问题 按 retry_advice 与 delivery_state 处理,禁止仅凭 Kind 重放写请求
UNSPECIFIED 未能确定账号处置原因 不要改账号状态;是否重试只看 retry_advice,没有建议时不盲目重放

三条硬规则

1. UNSPECIFIED 不是证据。 它表示服务端没有能 positively 确认账号处置原因的信号。 此时把账号标记为封禁、失效或需人工,都是在无证据的情况下做不可逆处置;它同样不代表写请求 一定没有送达。是否重试必须另看 retry_advice。要知道为什么拿不到证据,看下节的诊断字段(reason / failure_id 等)。

2. 禁止解析错误文案做分类。 只认 FailureDetail 的枚举字段。

这条是有事故背景的:分类曾经靠错误文本子串匹配,而频控错误的文案里含 "official-app 已通过" 字样,于是被判成权限拒绝——被限流的健康账号会被下游当成 被封禁而永久剔除。现在分类只由服务端在「知道为什么失败」的那个点产生,文案只用于 人读日志。

UNSPECIFIED 时看什么:诊断字段

kind 只回答「该对账号做什么」,拿不到正面证据时它保持 UNSPECIFIED。要知道具体是哪一种拿不到, 读 FailureDetail 的诊断字段(Python 侧是 error.failure 的同名属性)。它们只解释原因,不改变处置语义:

字段 内容 用法
reason 固定词表 FailureReason:LOGIN_NO_SESSION_TOKEN(username 步 200 但无 session_token)/ LOGIN_NO_SSO_TOKEN(密码或 2FA 步走完无 OAuth token)/ LOGIN_JF_BUSINESS_ERROR(JF 业务错误页)/ UPSTREAM_ERROR_CODE_UNMAPPED(有错误码但不在码表)/ UPSTREAM_HTTP_STATUS_UNMAPPED(状态码未映射且无错误码)/ UPSTREAM_BODY_UNDECODABLE(200 但正文解不开) 聚合统计、告警分组
upstream_error_codes 上游 errors[].code 数字 未映射的码在这里,拿去对 X 错误码表
upstream_error_message 解析后的 errors[].message 或 JF 错误文案,折叠空白、截断 200 字 仅展示。随语言与版本变化,禁止据此分支
next_component 登录停在的 X UI 路由(login_enter_password / totp / …) 区分「密码步没过」与「2FA 步没过」
jf_component JF 页 component 字段(如 errors) 同上
failure_id 16 位 hex,服务端日志同一条失败带 failure_id=<值> 拿 id 找运维查上游原文,不必猜
except TwitterAPIError as error:
    f = error.failure
    print(f.kind, f.reason, f.next_component, f.upstream_error_codes, f.failure_id)
    print(f.upstream_error_message)  # 只用来给人看

str(error) 也会带上 reason、failure_id 与上游文案,形如 Login failed (FAILURE_KIND_UNSPECIFIED/FAILURE_REASON_LOGIN_NO_SSO_TOKEN; grpc=UNAVAILABLE; failure_id=…): 原因未确定… [upstream: …]。

这些字段不含 token、cookie 或账号资料:upstream_error_message 只从解析后的错误字段取值,绝不是响应正文片段。 登录批次里 reason=LOGIN_NO_SSO_TOKEN 且停在 2FA 相关路由,多为 TOTP 码在 30 秒窗口内被重复使用(同账号并发或 立即重试),先错开窗口再看账号。

当前实际能发出哪些分类

分类枚举是完整的契约,但不是每个值现在都会真的出现——服务端只在拿到能positively 证明原因的协议信号时才发出对应分类,拿不到就是 UNSPECIFIED。按下表安排分支的优先级:

Kind 当前发出情况 依据
RATE_LIMITED ✅ 稳定发出 登录各步的限流页 + HTTP 429 + GraphQL code 88
RISK_BLOCKED ✅ 稳定发出 登录各步的官方 App 风控页
DEVICE_PROOF_MISSING ✅ 稳定发出 本地前置条件检查
TWO_FACTOR_REQUIRED ✅ 稳定发出 走到 2FA 步骤但无可用码
UPSTREAM_UNAVAILABLE ✅ 稳定发出 连接失败 + HTTP 5xx
SESSION_EXPIRED ✅ 发出 HTTP 401/403 + GraphQL code 32/89
BANNED ⚠️ 仅 GraphQL code 64,样本未验证 见下方警告
LOCKED_NEEDS_VERIFICATION ⚠️ 仅 GraphQL code 326,样本未验证 见下方警告
CREDENTIAL_INVALID ❌ 当前不会发出 尚无能与「账号锁定」「协议漂移」区分开的信号

密码错误目前会表现为 UNSPECIFIED(登录在用户名步或 2FA 步没能拿到预期令牌)。 不要为 CREDENTIAL_INVALID 写依赖它触发的逻辑——真机验收补齐判据后会在 CHANGELOG 说明。

置信度警告:BANNED / LOCKED_NEEDS_VERIFICATION 当前置信度较低。 这两类目前只在上游 GraphQL 返回明确错误码(64 / 326)时才发出,而这两个错误码在 Android 客户端协议上的真实样本尚未采集齐。在真机验收补齐之前,建议:

  • 收到 BANNED 时先做一次人工/二次确认再落库剔号,不要直接不可逆处置;
  • 或先按 NEEDS_HUMAN 走人工队列。

3. UNAVAILABLE 不等于可安全重放。 TransportFailure.delivery_state 的含义如下:

Delivery state 客观含义 写请求处置
NOT_SENT 失败发生在代理/TCP/TLS 等 HTTP 请求发送前 可按 BACKOFF_RETRY 退避重试
POSSIBLY_SENT 请求可能已经到达上游,但尚未拿到完整响应 必须先查证副作用
RESPONSE_STARTED 上游已经开始返回响应 必须先查证副作用

3.1 TLS_ALERT / TLS_RECORD_INVALID 是 OTHER 的收窄,不是新的重试门。

TransportFailure.cause 在 TLS_HANDSHAKE 阶段此前一律给出 OTHER(也就是「无法归因」), 把「对端拒绝握手」和「真的不知道」混成同一个值。本次变更把两种客观可识别的形态单独标出:

cause 客观含义 与重试的关系
TLS_ALERT 对端在握手期发来致命 alert(拒绝握手 / 无法协商) 与 OTHER 同格:NOT_SENT 时按 BACKOFF_RETRY 有界重连
TLS_RECORD_INVALID 对端在 TLS 端口回了非 TLS 字节(代理错误页 / 明文服务),隧道根本没建立 同上

★它们不改变 retry_advice 的取值规则★:这两个值与 OTHER 一样,既不在瞬时原因集合、 也不在确定性原因集合,重试结论一字不变。按 cause 做白名单的调用方需要把它们补进自己的 瞬时原因集合,否则会像从前一样在 OTHER 上放弃;但请与 CERTIFICATE_ERROR / PROTOCOL_ERROR / ALPN_MISMATCH 区分开——那三个是本机判定并主动中止握手, 换条连接也修不好,属确定性故障。

兼容性:这是加法式枚举扩展。旧客户端读到未知枚举值时应保留原始数字, 不得当成 UNSPECIFIED,也不得当成任何账号处置或重试依据。


二、AndroidAccountState 的版本边界

AndroidAccountState 里含设备身份,是不可再生资产:丢了就必须重新登录,而登录消耗 账号登录配额、且是强风控信号。所以升级 SDK/server 时能否沿用旧 state,直接决定升级成本。

当前接口尚未进入稳定版本,不承诺跨版本读取旧 state。AndroidAccountState.proxy 等字段可能在 正式版前发生破坏性调整;Python SDK、Go server 和持久化 state 必须来自同一构建版本。

上线前升级时,应先在副本上验证 load_account_state() 和一次只读 RPC,再替换持久化数据。若无法验证, 保留原 server/SDK 与 state 的可回滚组合,禁止用新版本批量覆盖唯一的设备状态。


三、容器健康检查

镜像内已实现两种探活方式,任选其一:

方式 A:健康检查二进制(compose 用这个最简单)

healthcheck:
  test: ["CMD", "/usr/local/bin/twitter-healthcheck"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 10s

方式 B:标准 gRPC 健康检查协议

服务端注册了 grpc.health.v1.Health,可直接 Check(空 service 名):

grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check

该接口免鉴权:即使服务端配置了 TWITTER_GRPC_API_KEY,健康检查也不需要带 x-twitter-api-key,所以编排系统的探针不需要持有密钥。

二进制默认探本机默认端口;用 TWITTER_GRPC_HEALTH_ADDR 覆盖地址, TLS 场景用 TWITTER_GRPC_HEALTH_SERVER_NAME 指定证书名。


四、账号使用节奏(建议区间)

⚠️ 下列数值是自有测试账号上的观测经验,不是 X 官方承诺的配额。 X 不公开限流阈值, 且阈值随账号年龄、历史行为、IP 段浮动。请把它们当作起始参数,按自己的实际失败率调整。

冷启动登录频次

项 观测 / 建议
触发频控的观测点 单账号 + 单 IP 短时间内连续 8 次以上冷启动登录后触发
触发后的表现 Login 在早期步骤快速失败(约 6–7 秒返回),分类为 RATE_LIMITED
建议的同账号最短重登间隔 ≥ 30 分钟;批量场景建议拉到小时级
冷却时长 小时级,实测 ≥ 4.5 小时(上游文案只说「请稍后重试」,极具误导性)

⚠️ 触发频控后不要立即重试。 重试本身又是一次失败登录,只会延长冷却。 上游返回的「我们已临时限制你的登录。请稍后重试。」读起来像分钟级,实测是小时级。

最重要的一条:不要为每个任务各登录一次。 登录一次拿到完整 AndroidAccountState, 用 client.account(state) 绑定后复用给所有业务调用;只在收到 SESSION_EXPIRED 时才重登。 把 login 当成会话级操作,而不是请求级操作——这一条比调任何冷却参数都有效。

会话有效期

OAuth 1.0a 会话没有固定过期时间,正常使用下可长期存活。会失效的典型情况:

  • 用户或平台在别处主动登出 / 改密码;
  • 账号被锁定或封禁;
  • 长期不用后被平台回收。

不要按固定周期主动重登(那是在没有必要的情况下反复触发风控)。正确做法是 按失败驱动:收到 SESSION_EXPIRED 再重登。

发帖节奏

项 建议
单账号最小发帖间隔 ≥ 5 分钟起步,新号或历史干净度未知的号建议更长
批量场景 账号之间打散,避免同一 IP 段短时间集中发帖

发帖类 RPC 收到 RATE_LIMITED 时退避并拉长该账号的间隔,不要立即重试。


四点一、出口代理

公开接口只接受强类型 ProxyConfig,协议固定为 SOCKS5H:

from twitter_sdk import ProxyConfig

proxy = ProxyConfig(
    host="proxy.example",
    port=1080,
    username="account-user",  # IP 白名单代理可同时省略 username/password
    password="account-pass",
)
state = await client.login(username=U, password=P, proxy=proxy)

不再接受 socks5h://user:pass@host:port 字符串。host 不带 scheme 和端口;端口范围为 1..65535;用户名与密码必须同时提供或同时省略。非法配置在任何网络请求发出前返回 INVALID_ARGUMENT,错误消息不会回显代理凭据。

协议固定为 SOCKS5H,是为了让目标域名由代理解析,避免服务端 DNS 泄漏。不会支持或自动降级到:

协议 拒绝原因
SOCKS5(本地解析) 服务端会先解析目标域名,产生 DNS 泄漏
HTTP/HTTPS CONNECT 目标 host 会出现在代理 HTTP 层

其它要点:

  • 代理是每账号 AndroidAccountState.proxy 的一部分,不读取 HTTP_PROXY / HTTPS_PROXY;
  • 登录结果中的 proxy 来自 transport 的实际生效配置,不会出现 state 声称走代理、实际却直连;
  • 代理失败绝不自动切到直连,也不会更换 TLS 指纹或关闭证书校验;
  • 隧道对 TLS 层透明,ClientHello 与 SNI 始终对应真实目标;
  • proxy=None 表示从部署机直连,仅建议用于本地开发。

四点二、账号巡检字段:能拿到什么、拿不到什么

UsersApi/Me 与 UsersApi/UserByRestID 返回的 User 含以下巡检字段 (字段来源是真机采集到的上游 legacy 块实际内容,不是按需求推断的):

字段 类型 说明
location string 用户自填的地区文本,可为空、可任意填写
created_at int64 注册时间,unix 秒(上游是显示字符串,已转成时间戳);无法解析时为 0
protected bool 账号已设为私密
profile_image_url string 头像地址(https)
suspended bool 账号被平台封禁
needs_phone_verification bool 被要求手机验证

账号所在地(account_based_in)——在 AboutProfile 端点,不在 UserByRestID

更正此前结论:早先写「Android 协议不提供该字段」是错的 —— 那是把「UserResultByIdQuery 的 legacy 块没有它」误推成「协议没有它」。它在另一个独立端点 AboutProfileQuery 上。

  • 来源:UsersApi/AboutProfile(rest_id) / AboutProfileByScreenName(screen_name), 返回 raw_json,其 AboutProfile 片段含 account_based_in / created_country / created_country_accurate / location_accurate / username_changes(用户名改过几次)等。
  • location 仍是另一回事:UserByRestID 里的 location 是用户自填地区,不是平台判定的所在地。 要平台判定的所在地,用 AboutProfile 的 account_based_in。
  • ⚠️ 尚未真机验证:该 RPC 已实现(Go+Python 全链路),但 AboutProfileQuery 是否需要 features、 这次调用能否真拿到值(可能 null / 需权限),待真机验收。字段存在于 schema ≠ 服务器这次会返回值。

suspended 与失败分类 BANNED 是两条独立证据

suspended 来自读取到的资料,FailureKind.BANNED 来自调用被拒。 两者互不推断: - 读到 suspended=true 是可信的封禁信号,可直接用于巡检落库; - 收到 BANNED 分类的置信度较低(见第一节),做不可逆处置前建议用一次 Me / UserByRestID 读取 suspended 来二次确认。

其余 legacy 字段(media_count、favourites_count、profile_banner_url 等) 未提升为结构化字段,可从 raw_json 的 user_result.result.legacy 自行解析。


四点三、写权限巡检(UsersApi/WriteStatus):判「还能不能干活」

suspended 只能判「死没死」。能登录、资料正常、但发帖被拒的只读限权账号, suspended 是 false —— 只看它会把这类账号判成正常,继续排进发帖任务,每轮必败。

UsersApi/WriteStatus(Python:account.users.write_status())专门补这一格。 它只读拉一次主页流(X 客户端自己就是靠主页流里注入的限权横幅知道账号只读的), 不试发帖、无写副作用、不消耗发帖配额。

一、按 status 分支,不要看 reason

status 含义 建议处置
WRITE_STATUS_WRITABLE 未检测到限权信号 正常排任务
WRITE_STATUS_READ_ONLY_SUSPENDED 只读限权:能登录能看,发帖被拒 暂停排发帖任务并观察,不要剔除
WRITE_STATUS_HARD_SUSPENDED 硬封 剔出选号池(建议按第四点二再用 Me.suspended 复核)
WRITE_STATUS_LOCKED 账号被锁定、待验证 人工介入
WRITE_STATUS_LOGIN_INVALID 登录态失效,账号本身可能正常 先用存量 state 复核(见下),确认确属会话失效再登录;不要动账号状态
WRITE_STATUS_RATE_LIMITED 命中限流 退避后重查;绝不能当封号处置
WRITE_STATUS_UNKNOWN 没查到 不得当成可写;记录并重查

★WRITABLE 的语义是「巡检没发现问题」,不是「已确认能发帖」★ —— 这是巡检类推断的固有天花板。 空时间线(新号、零关注)也会判 WRITABLE。

一点五、巡检自身的配额约束(批量巡检必读)

每次 write_status() 消耗一次主页流调用。两条约束:

  • 节流:批量巡检 N 个账号就是 N 次上游调用,跑太密会把账号自己打成 RATE_LIMITED, 而 RATE_LIMITED 的指引是「退避后重查」—— 不加间隔就会形成放大回路。 建议同账号两次巡检至少间隔数分钟,跨账号并发也要限流。
  • ★不要因为 LOGIN_INVALID 就自动冷启登录★:401/403 一律映射成会话失效, 但代理故障或风控闸门返回的 403 也长这样。一次全量巡检可能触发 N 次冷启登录, 而冷启登录按账号/IP 限频(触发后冷却数小时),代价远大于巡检本身。 正确做法是先用同一份存量 state 调一次 Me 复核:Me 也失败才考虑重新登录, Me 正常则说明是出口或风控问题,应查代理而不是重登。

二、writable 是三态,缺席不是 false

resp = await account.users.write_status()
if resp.HasField("writable"):
    can_post = resp.writable          # 查到了:True / False
else:
    can_post = None                   # 没查到(UNKNOWN / RATE_LIMITED)

UNKNOWN 与 RATE_LIMITED 时字段缺席:前者是判不出来,后者是账号可能完全健康、只是请求太快。 这两种情况给 False 会把健康账号误归进限权池,正是本能力要避免的误判方向。

枚举字符串名用 users_pb2.WriteStatus.Name(resp.status) 取。

三、reason 只能展示,禁止解析

只有 READ_ONLY_SUSPENDED 时 reason 是平台横幅原文(不翻译、不映射);其余状态是本服务的 固定说明文案,WRITABLE 时为空串。分类一律走 status,任何情况下都不要对 reason 做子串匹配 —— 本仓有过按错误文案分类、把限流误判成封禁、导致健康账号被永久剔除的事故。

四、★这个 RPC 的上游失败不走 FailureDetail★

与本项目其余 RPC 不同:账号被封 / 被锁 / 会话失效 / 被限流不会抛 gRPC 错误, 而是作为 status 正常返回 —— 对巡检而言那是查询的结果,不是查询的失败。 只有传输层失败(代理不可达、超时、5xx、无法分类)才返回 gRPC 错误。

因此这些情况下拿不到 FailureDetail.upstream_http_status,改看响应里的 http_status (上游主页流的 HTTP 状态码,排障用)。banner_entry_id 是命中的横幅条目 id,判据漂移时用于回溯。

五、当前验证状态

READ_ONLY_SUSPENDED 分支尚未经真机限权账号验证(横幅样本为构造 fixture)。 其余分支(可写 / 各类上游错误码)走的是本仓既有的、已在真机上跑过的失败分类链路。 另外 HARD_SUSPENDED 与 LOCKED 依赖的错误码映射同样尚无真机样本, 做不可逆处置(永久剔除账号)前,建议再读一次 Me.suspended 复核(见第四点二)。

接入时建议先用一个已知只读限权的账号对拍一次;结果与预期不符请反馈, 不要自行按 reason 文案兜底判断。


四点五、删帖:协议层面拿不到删除结果

PostsApi/DeletePost 会返回 raw_json(DeleteTweet 的原始 data 节点), 但它无法告诉你帖子是不是真的被删掉了。

实测:删除一条真实存在的帖子、再用同一个 tweet_id 删一次(此时帖子已不存在), 两次响应逐字节相同:

{"delete_tweet":{"__typename":"DeleteTweetResponse"}}

这是上游协议的限制,不是本项目的实现缺陷。X 的 DeleteTweet 对 「删掉了」和「这条帖子本来就不存在 / 不属于本账号」返回同一个响应。

因此: - RPC 正常返回(无 gRPC 错误)只能说明请求被接受,不能说明帖子此前存在; - 需要确认帖子确实消失时,删除后按上面第六节的方式查 UserTweets 反查; - 不要在自己的系统里凭 DeletePost 的返回值区分「撤帖成功」与「帖子早就没了」。

我们不提供 deleted 布尔字段:在协议给不出依据的情况下,一个恒为 true 的字段 比没有这个字段更有害。


五、2FA 账号:传 seed 而不是传当前码

login() 同时接受 totp(当前 6 位码)和 totp_seed(base32 密钥)。优先传 seed:

state = await client.login(username=U, password=P, totp_seed=SEED)

登录是一条跨多个上游请求的状态机,从调用方算出码到状态机真正走到 2FA 步骤之间存在延迟; 本地算好的码可能在途中就过期了。传 seed 时服务端在执行 2FA 步骤的那一刻才算码, 不存在这个竞态。两者同时传时 totp 优先。

totp_seed 是明文密钥,仅在服务端内存中使用、不写日志。远程部署务必启用 TLS 与 API key。

重试归调用方

每次 login() 只执行一次完整冷启动并随机选择一台 weight>0 金标。SDK 和 Go server 都不做内部重试,也不会替调用方更换代理 Session。调用方收到结构化失败后,自行决定是否退避、 换代理或再次调用;RATE_LIMITED 不得立即重试,密码、2FA、封禁和锁定也不能靠换设备解决。


六、写操作超时后怎么办

写类 RPC(CreatePost / Reply / Quote 等)不是幂等的,且不接受客户端幂等键—— 上游 X 协议本身没有幂等键,服务端也不保存跨请求状态(见项目与接入的无状态设计)。

超时不等于没发出去。当错误满足下列条件时,SDK 会给出 RETRY_ADVICE_VERIFY_EFFECT_THEN_RETRY:

  • transport.delivery_state 是 POSSIBLY_SENT 或 RESPONSE_STARTED;
  • 当前 RPC 是非幂等写操作。

正确处置是先查证再决定,不要直接重试:

  1. 退避至少 3 秒(给上游写入传播留时间);
  2. 调 TimelineApi/UserTweets,传本账号 rest_id,取最新一页;
  3. 按正文或 tweet_id 匹配:
  4. 命中 → 上一次其实成功了,记成功,不要重发;
  5. 未命中 → 再退避一档重查;连续几档都未命中才判定失败。

查证只需要最新一页,不需要翻页游标。

退避不能省。 实测:发帖后立即查询,正文与 tweet_id 都查不到;退避 3 秒后 两者同时命中。时间线是最终一致的,零延迟查询会把成功的发帖误判成失败—— 恰好是这套配方要避免的那种假失败。

3 秒是单次实测值,不是上游承诺的传播上限。生产环境建议按 3s → 8s → 20s 逐档重试, 全部未命中再判失败。