集成方运维须知¶
面向把本项目接进自己调度系统的调用方。这里只放接入后必须知道、否则会踩坑的内容: 失败怎么分类、状态能不能跨版本用、容器怎么探活、账号该按什么节奏用。
一、失败原因分类(必读)¶
为什么 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 名):
该接口免鉴权:即使服务端配置了
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 删一次(此时帖子已不存在),
两次响应逐字节相同:
这是上游协议的限制,不是本项目的实现缺陷。X 的 DeleteTweet 对
「删掉了」和「这条帖子本来就不存在 / 不属于本账号」返回同一个响应。
因此:
- RPC 正常返回(无 gRPC 错误)只能说明请求被接受,不能说明帖子此前存在;
- 需要确认帖子确实消失时,删除后按上面第六节的方式查 UserTweets 反查;
- 不要在自己的系统里凭 DeletePost 的返回值区分「撤帖成功」与「帖子早就没了」。
我们不提供 deleted 布尔字段:在协议给不出依据的情况下,一个恒为 true 的字段
比没有这个字段更有害。
五、2FA 账号:传 seed 而不是传当前码¶
login() 同时接受 totp(当前 6 位码)和 totp_seed(base32 密钥)。优先传 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 是非幂等写操作。
正确处置是先查证再决定,不要直接重试:
- 退避至少 3 秒(给上游写入传播留时间);
- 调
TimelineApi/UserTweets,传本账号rest_id,取最新一页; - 按正文或
tweet_id匹配: - 命中 → 上一次其实成功了,记成功,不要重发;
- 未命中 → 再退避一档重查;连续几档都未命中才判定失败。
查证只需要最新一页,不需要翻页游标。
退避不能省。 实测:发帖后立即查询,正文与
tweet_id都查不到;退避 3 秒后 两者同时命中。时间线是最终一致的,零延迟查询会把成功的发帖误判成失败—— 恰好是这套配方要避免的那种假失败。3 秒是单次实测值,不是上游承诺的传播上限。生产环境建议按 3s → 8s → 20s 逐档重试, 全部未命中再判失败。