s-accountpoolkit
Зрелый пул аккаунтов/сессий поверх s-librarykit — доменный слой над SessionStore/SessionPool/RateLimiter/RotatingAuth/antibot-транспортами. Обобщение gemini-balancer в переиспользуемый кит.
import-имя accountpoolkit · dist-имя s-accountpoolkit (как s-librarykit → librarykit).
Что даёт (сверх librarykit)
- Account со статус-машиной —
available / cooldown / quota_exhausted / disabled / blockedс причинами и таймстемпами (не строка+is_active). - Quota / подписки —
QuotaDataunion (dual-window weekly+5h / header-based / JWT-claims), приоритетULTRA > PRO > FREE, model-level изоляция. - Таксономия подписок по семействам (
accountpoolkit.subscription) — провайдеры одного вендора делят подписку, но тиры разные при общей оси free/paid: google (Plus/Pro/Ultra 5x·20x), openai (Go/Plus/Edu/Pro 5x·20x), anthropic (Pro/Max 5x·20x), telegram (Premium). Каждый тир несёт грубыйSubscriptionTierдля селектора; уAccount—plan/subscription_expires_at/project(срок подписки + ярлык-проект). - Rate-limit + circuit-breaker — разбор 429/403/5xx/Retry-After, exp-backoff по причинам, cooldown отдельно от CB-open, авто-recovery.
- Selector — двухслойный (жёсткий eligibility-фильтр → Power-of-Two-Choices), приоритет-каскад подписка→квота→health→reset, sticky-сессии, slow-start.
- OAuth-ротация — single-flight (double-checked-locking) на аккаунт, атомарный rolling-refresh, fallback-цепочка OAuth-клиентов,
invalid_grant→ карантин, проактивный фоновый рефреш, keyring-экспорт токена. - Egress-пул — привязка аккаунт↔прокси (sticky IP) vs глобальный пул, стратегии (RR/Weighted/LeastConn/Priority/P2C), health-check, настоящий per-proxy circuit-breaker/quarantine.
- Общий вход у поставщика личности (SSO) — вход в Google хранится ОДИН раз и переиспользуется всеми сервисами и пулами; отдельное состояние «сессия сервиса жива, вход отозван»; продление одно на всех (см. ниже).
- Журнал событий идентичности — append-only история аккаунта (выдача, вход, продление, ротация, операция, отказ по лимиту, челлендж, бан, перелогин, смена выхода) с выходом/устройством/уликой и выборкой «что предшествовало бану». Без секретов by-design; отказ журнала не роняет работу (см. ниже).
- Риск аккаунта одним числом — показатель из журнала (свежее весит больше, бан ≠ отказ по лимиту) со слагаемыми, из которых он сложился, и третьим состоянием «истории не хватает». Селектор уточняется им осознанно — флагом, с режимом наблюдения (см. ниже).
- Темп бизнес-операций — аккаунт не совершает операций чаще человеческого темпа, заданного для РОДА операции (отправка, публикация, поиск, экспорт, вход): границы в час и в сутки, промежуток между двумя подряд, пауза с разбросом. Считается по журналу, работает проактивно (не совершить раньше срока, а не отреагировать на 429), включается осознанно — флагом, с режимом наблюдения (см. ниже).
- Устройство аккаунта (персона) — одно устройство на аккаунт: заводится при первой выдаче, лежит в реестре, переживает перезапуск и версионируется (
corekit.persona). Выдача отдаёт персону ВМЕСТЕ с адресом выхода и сводит их между собой; смена устройства — событие журнала (см. ниже). - Выход по требованию — тип выхода (
datacenter | residential | mobile+ честное «неизвестно»), ASN и страна в модели прокси/инбаунда; дорогой выход выдаётся только когда его просят (см. ниже). - Import/export — версионированный конверт, идемпотентный upsert, чтение чужих сторов.
- Headless
AccountService-фасад +clikitCLI (json-by-default); опц. REST admin ([rest],accountpoolkit.rest.create_admin_app) + cloudflared quick-tunnel (accountpoolkit.tunnel, нужен бинарьcloudflaredна PATH — не pip-пакет).
Секреты — только через librarykit SessionStore (envelope KEK/DEK) / SecretStore (keyring+fallback). Никакого plaintext.
Провайдер-специфика (gemini / codex / …) — через AccountProvider Protocol-плагины; ядро её не знает.
Установка
uv add s-accountpoolkit # ядро
uv add "s-accountpoolkit[egress]" # + antibot health-транспорт для egress-пула
uv add "s-accountpoolkit[cli]" # + CLI `accountpool` (json-by-default)
import-имя — accountpoolkit. Корень пакета ЛЕНИВЫЙ (PEP 562): import accountpoolkit не поднимает ни sqlalchemy, ни librarykit (101 мс против 2019 мс
у жадного корня) — тяжёлое приезжает при первом обращении к имени. На способ
работы это не влияет: apk.AccountStore, from accountpoolkit import * и
accountpoolkit.repository дают ровно те же объекты, что и раньше.
AccountService — headless-фасад над всеми слоями:
import accountpoolkit as apk
from librarykit.sessions import SessionStore
store = apk.AccountStore(SessionStore(root=None, encrypt=True), social="myservice")
pool = apk.AccountPool(store, tracker=apk.RateLimitTracker())
svc = apk.AccountService(store=store, pool=pool)
choice = await svc.acquire(require_tier=apk.SubscriptionTier.PRO)
if choice:
... # запрос через choice.proxy (egress) + choice.account (device);
# секреты хранятся отдельно (envelope): await store.load_creds(choice.ref)
await svc.report(choice.ref, response=resp) # 429/5xx → cooldown/circuit-breaker
Общий вход у поставщика личности (SSO)
Аккаунт, заведённый «через Google», держится на ДВУХ входах сразу: сессии самого сервиса и входе у Google. Сроки жизни у них разные, и умирают они порознь — ChatGPT продолжает отвечать, когда войти в Google уже нельзя. Пока состояний было два, этот случай прятался в «всё хорошо» и всплывал в час, когда вход понадобился.
Вход хранится один раз. SsoLogin — запись по ключу «поставщик + кого он
узнаёт» (google + почта). Тело входа лежит в общем хранилище сессий по
каноническому адресу sso_session_ref(...) — профиль sso, а не профиль
потребителя, иначе у каждого профиля завелась бы своя копия. Аккаунты на него
ССЫЛАЮТСЯ: Identity.sso_login_id в реестре и пара «провайдер + почта» в строке
состояния сессии. Копии на навык нет ни одной.
Три состояния вместо двух (SessionHealthSnapshot.auth_state):
| состояние | что произошло | что чинить |
|---|---|---|
healthy |
сессия сервиса жива, плохих улик о входе нет | ничего |
sso_expired |
сессия сервиса ЖИВА, вход у поставщика МЁРТВ | восстановить вход; сервис не трогать, работа идёт |
unauthenticated |
мертва сама сессия сервиса | перелогин в сервис |
unknown |
сервис недоступен / лимит / капча | переспросить позже |
logged_out ставится ТОЛЬКО по прямой улике: поля формы входа (identifierId,
Passwd) или ответ 401/403 от поставщика. Ссылка «войти» на странице и сетевой
сбой уликами не считаются — по догадке выключаются рабочие аккаунты. Улика
хранится рядом с вердиктом и уходит в отчёт.
Продление одно на всех. Человек входит ОДИН раз; поколение входа растёт, и каждый навык узнаёт об этом сам — его сессия отстала от поколения, а новое тело уже лежит в общем хранилище:
from accountpoolkit.domain import SsoEvidence
from accountpoolkit.services import SsoService
sso = SsoService(session_factory, store=db_store)
sso.attach("google", "me@gmail.com", service="gemini", account="me@gmail.com", profile=uid)
sso.attach("google", "me@gmail.com", service="chatgpt", account="me@gmail.com", profile=uid)
# наблюдение по улике — одно на ВСЕ сервисы, вход-то один
sso.observe("google", "me@gmail.com", SsoEvidence(status_code=401)) # → logged_out
# вход перехвачен заново: тело уезжает в общее хранилище, поколение +1
await sso.publish("google", "me@gmail.com", storage_state)
# навык спрашивает про СВОЮ сессию и подхватывает общий вход — человека не зовут
if sso.needs_resync("google", "me@gmail.com", service="gemini",
account="me@gmail.com", profile=uid):
body = await sso.body("google", "me@gmail.com") # один источник на всех
... # пере-минт сессии сервиса
sso.mark_synced("google", "me@gmail.com", service="gemini",
account="me@gmail.com", profile=uid)
Аккаунт с мёртвым входом НЕ выключается из выдачи: работа идёт, и отсечь его значило бы сломать её. Чинить надо вход, а не сервис.
await sso.adopt(...) дополнительно помечает сессию в ИНДЕКСЕ хранилища
(credential_group — задел librarykit «один логин ⇒ несколько сетей»), чтобы
ответ на «кто ляжет вместе с этим входом» был один, а не два расходящихся.
Журнал событий идентичности (что предшествовало бану)
У аккаунта есть ИСТОРИЯ, а не только счётчики и мгновенное состояние. Журнал append-only живёт в том же хранилище, что реестр, и отвечает на вопрос, ради которого заведён: что происходило перед баном.
from accountpoolkit import IdentityJournal
journal = IdentityJournal.open() # или Gate(...).journal
# ЦКП: события до последнего бана И сам бан последней строкой
for e in journal.before_ban(identity_id=17, limit=20):
print(e.at, e.kind, e.outcome, e.code, e.egress_host or "—", e.egress_country)
# лента за окно (по аккаунту реестра или по имени аккаунта)
journal.feed(identity_id=17, since=..., limit=100)
journal.feed(account="me@gmail.com", service="gemini-chat")
Из CLI (json-by-default):
accountpool journal before-ban --identity-id 17 --limit 20
accountpool journal feed --account me@gmail.com --hours 24 --kind limit
Событие отвечает на «кто, когда, чем и с каким исходом»: момент (UTC), аккаунт и
сервис, сессия и проект-потребитель, ВЫХОД (хост/страна/ASN ноды), устройство
(ссылка — заполняется разметкой устройств), род (EventKind: выдача,
освобождение, вход, продление, ротация токена, операция, отказ по лимиту,
челлендж, бан, перелогин, смена выхода), исход (EventOutcome) и улику —
МАШИННЫЙ КОД из уже существующих словарей (LimitReason, HealthState,
SsoState, AccessState).
Секретов в журнале нет и быть не может. Не по дисциплине пишущего, а по устройству: свободного поля под «тело ответа» или «подробности» в контракте не существует, а улика и приметы проходят фильтр формы и длины — токен, кука и заголовок авторизации туда не пролезают. Журнал append-only: попавший в него секрет остался бы там навсегда.
Журнал не важнее работы. Его отказ никогда не роняет горячий путь: выдача аккаунта происходит и тогда, когда записать событие не удалось.
Ретенция — 90 дней (ACCOUNTPOOL_JOURNAL_RETENTION_DAYS, ноль — «не
убирать»). Столько живёт окно расследования: бан выясняется не сразу, а всплеск
надо сравнивать с несколькими нормальными циклами недельных лимитов; дальше
квартала — уже архив, а не расследование.
Риск аккаунта одним числом
Журнал даёт историю, а показатель риска сводит её к ОДНОМУ сравнимому числу: насколько рискованно идти этим аккаунтом прямо сейчас. Раньше на этот вопрос отвечать было нечем — признаки описывали момент (жив ли, сколько осталось, сколько подряд упало), а рискованность это свойство истории.
from accountpoolkit import IdentityJournal
from accountpoolkit.services import RiskScorer
risk = RiskScorer(IdentityJournal.open()).assess(identity_id=17, account="me@gmail.com")
print(risk.score, risk.level) # 63.4 high
print(risk.explain()) # me@gmail.com: риск 63.4 (high) по 41 событиям за 30 дн
# — ban 60.0 (1), challenge 2.4 (1), limit 1.0 (12)
accountpool journal risk --account me@gmail.com
Как считается: свежее весит больше (полураспад 14 дней — бан вчера и бан
три месяца назад это разные вещи), роды весят по-разному (бан 60, челлендж
15, отказ по лимиту 1 — лимит про исчерпание, а не про риск), сумма обрезается
сотней. Рядом с числом всегда едут слагаемые (components): род, сколько
событий, вклад в баллах, когда случилось последнее и какие коды-улики
встретились — иначе показателем нельзя пользоваться при расследовании.
«Нет данных» — не «низкий риск». У свежего аккаунта уровень unknown, а не
low: он непроверен, а не безупречен. Плохие улики при этом перебивают нехватку
данных — единственное событие-бан даёт high, а не «мало данных».
Связь с селектором — осознанная. По умолчанию учёт риска ВЫКЛЮЧЕН и не
делает ни одного лишнего запроса. Включается флагом
ACCOUNTPOOL_RISK_AWARE_SELECTOR: shadow — считать и логировать расхождения,
не трогая боевой выдачи; on — уточнять каскад (полоса риска встаёт между
health и reset). Отсечения по риску нет: рискованный аккаунт идёт позже, но
остаётся кандидатом.
Темп бизнес-операций (не чаще, чем это делает человек)
Технический темп кит держал давно — RPM/RPD, окна квот, circuit-breaker. Всё это про КАНАЛ: «сто запросов в минуту» не говорит ничего о том, нормально ли для человека опубликовать сорок постов подряд. А признаки современная защита строит вокруг БИЗНЕС-ОПЕРАЦИЙ: подделать один заголовок дешевле, чем весь жизненный цикл аккаунта, — поэтому проверяют жизненный цикл.
from accountpoolkit import IdentityJournal, OperationPacer
pacer = OperationPacer(IdentityJournal.open())
план = pacer.plan("publish", identity_id=17, account="me@vk.com")
print(план.explain())
# me@vk.com: publish — ждать 1123.4 сек (hour_budget); за час 10/10,
# за сутки 23/40; режим on
pacer.wait("publish", identity_id=17) # выждать (в корутине — asyncio.sleep)
... # сделать операцию
pacer.note("publish", identity_id=17) # отметить в журнале
Род операции — данные, а не набор if. Кит знает из коробки пять родов —
send (45/час, 300/сутки), publish (10/40), search (120/800), export
(6/30), login (3/10) — с минимальным промежутком между двумя подряд.
Границы перебиваются конструктором или переменной окружения
ACCOUNTPOOL_OPERATION_TEMPO (publish=6/25/90, my_export=2/8/300), а
незнакомый род получает осторожное умолчание: навык, заведший свою операцию,
узнаёт об этом не остановкой работы.
Отклонения нет — есть отсрочка. План говорит «подожди столько-то», а не «нельзя»: отказ вернулся бы вызывающему ошибкой, ошибка ушла бы в ретрай, а ретрай — это ещё более плотный темп.
Пауза с разбросом. Ровно N операций в час через равные промежутки — сам по
себе машинный признак; пауза гуляет в пределах +35% (разброс только вверх —
пауза короче границы перестала бы быть границей).
Считается по журналу, который уже пишется. Род операции едет машинным кодом
в IdentityEvent.code события operation, выборка дешёвая (только моменты, по
индексу, окно — сутки). Второго счётчика в памяти нет: он не пережил бы
перезапуск, а история переживает.
Включение осознанное. По умолчанию темп ВЫКЛЮЧЕН и не делает ни одного
запроса. ACCOUNTPOOL_OPERATION_PACE=shadow — считать и показывать, что было
бы отложено (plan.advised_seconds, pacer.last_shadow), ничего не
откладывая; on — откладывать. Поломка подсчёта даёт честное «можно сразу»
(pace_unavailable) — как и у журнала, темп не важнее работы.
Выход по требованию (тип и ASN)
У выхода есть тип (datacenter | residential | mobile плюс честное
unknown), ASN и страна: именно их защита видит на краю, ещё до первого
запроса. Выход выдаётся ПО ТРЕБОВАНИЮ:
mgr.choose() # требования нет → дешёвый (датацентр)
mgr.choose(require="residential") # нужен домашний адрес → резидентский/мобильный
mgr.bind(identity_id, "nl-1", require="residential") # мимо требования → ValueError
Резидентские прокси стоят денег, поэтому умолчание их не расходует: дорогой
выход выдаётся по явному требованию, а не «на всякий случай». Неразмеченная
нода (unknown) требование дороже датацентра НЕ закрывает — отказ честнее
подмены. Разметка задаётся при заведении инбаунда (add(..., egress_kind=…, asn=…)) и переживает рестарт.
Устройство аккаунта (персона)
Выдача отдаёт не только адрес выхода, но и устройство, которым этот аккаунт представляется:
granted = await gate.acquire("проект", "gemini-chat")
granted.proxy_endpoint # socks5h://127.0.0.1:10801 — чем ходим
granted.persona.persona_id # dev-d648b243a3e8 — КЕМ приходим (стабильно)
granted.persona.version # 3 — поколение заявления
granted.persona.timezone # Europe/Helsinki — часы из страны выхода
Одно устройство на аккаунт, а не на вызов. Раньше выдача отдавала только
адрес, устройство до потребителя не доезжало вовсе, и один аккаунт
представлялся разными клиентами при одном и том же IP. Для скоринга устройства
это худший из рисунков: стабильная сеть при плавающем клиенте читается как угон
сессии. Персона лежит в строке device (имя и поколение — колонки, слои — JSON)
и одна на все сервисы аккаунта: у человека один ноутбук на все сайты.
Обновление — это bump, а не подмена. Обновился Chrome, сменилась
платформа, переехал выход — растёт version, persona_id остаётся. Смена имени
означала бы для защиты нового посетителя: потерянное узнавание устройства и
подтверждение входа на ровном месте.
Персона сводится с выходом, и молча это не делается. Часы принадлежат
машине, а машина стоит там, откуда приходит запрос: пояс, спорящий со страной
выхода, — ошибка уровня «ходить нельзя». Такое расхождение ЧИНИТСЯ (выход
переставили мы) — с поднятием поколения и записью device_switch в журнал. А
расхождение, которое сменой места не лечится (окно больше экрана, десктопная
платформа при мобильном признаке, рукопожатие, отставшее от заголовков), —
PersonaConflict: чинить его значило бы подменить само устройство.
Чем мы выглядим (версия браузера, язык) кит НЕ пинит — это знает netkit, одно место на систему; наблюдение можно задать явно:
from accountpoolkit.services import Gate, PersonaService, PersonaTraits
gate = Gate(persona=PersonaService(traits=PersonaTraits(browser_version="152.0.8100.10")))
Схема хранилища: сверка, применение, ОДИН дом
Схема живого файла обязана отвечать на вопрос «совпадаю ли я с кодом» ДО первого
чтения данных. Пока такого ответа не было, расхождение приезжало наружу
OperationalError: no such column: identity.sso_login_id — из глубины запроса,
где его ловил чужой except и выдавал наверх «пул пуст» при двух аккаунтах и
восьми сессиях в файле. Ответ был неверен дважды: и про пустоту, и про
исправность.
accountpool schema status # три исхода: matches / stale / unreadable
accountpool schema apply # сухой прогон: показать дельту, НЕ трогать
accountpool schema apply --yes # копия рядом → применить → сверить ЗАНОВО
accountpool schema homes # один дом или два, и как их свести
from accountpoolkit.repository import check_schema, get_engine
delta = check_schema(get_engine())
print(delta.state) # SchemaState.STALE
print(delta.summary()) # «схема … СТАРШЕ кода — нет колонок: identity.sso_login_id …»
print(delta.rows) # {'identity': 2, 'session': 8, …} — база НЕ пуста
Три исхода не схлопываются в два. matches — читать можно; stale —
названо поимённо, каких таблиц и колонок не хватает и устарел ли уникальный ключ
адреса; unreadable — до схемы не добрались вовсе (нет прав, битый файл), а это
лечится другим, и выдавать одно за другое значит отправить человека чинить не то.
Отдельно назван первый запуск (database_is_new): пустое хранилище — это не
старая форма поверх живых данных. Вместе с дельтой едут счётчики строк, чтобы
«схема отстала» и «в базе пусто» больше никогда не путались.
Правка подтверждается и показывает разницу. schema apply без --yes не
трогает ничего. С --yes порядок такой: снять дельту → положить копию файла
рядом (registry.db.pre-schema-<время>.bak) → применить → сверить заново.
«Применилось» доказывается второй сверкой, а не тем, что команда не упала.
Читающая дверь ловит расхождение сама. Registry(url=…) и open_registry
без init сверяют схему на открытии и поднимают SchemaOutdated с дельтой
вместо невнятного отказа на первом же SELECT.
Про дом — он ОДИН. Схема у реестра аккаунтов и у тела сессий одна
(Base.metadata), а путей по умолчанию было два: нейтральный platform-appdir и
канонный каталог сессий корневого кита. Границы предметной области здесь нет: не
бывает «реестра аккаунтов» отдельно от «сессий этих аккаунтов» — балансировать
нечем, если аккаунт в одном файле, а его вход в другом. Это были два умолчания
об одном хранилище, а не два хранилища, и разошлись они молча: у владельца в
одном файле лежали 2 аккаунта, 8 сессий, 12 сервисов и 42 модели при пустом
session_state, а 12 живых входов — в другом.
С 0.5.25 умолчание одно: ~/.sessions/sessions.db (SESSIONS_DB_URL /
SESSIONS_HOME), туда же по умолчанию ведёт и реестр. Явные адреса
(ACCOUNTPOOL_DB_URL / ACCOUNTPOOL_HOME) по-прежнему перебивают всё. Старый
platform-appdir не брошен: пока в его файле есть таблицы, читается он, и кит
говорит об этом вслух (лог + раздел legacy в schema homes) — тихая смена пути
под живым потребителем выглядит как потеря данных из ниоткуда. Пустой файл, заново
созданный по старому адресу потребителем прежней версии, домом не считается:
иначе сведение отменялось бы само.
accountpool storage merge # сухой прогон: построчная дельта, НИЧЕГО не тронуто
accountpool storage merge --yes # копия ОБОИХ файлов → перелив → сверка → источник в сторону
Сведение — слияние, а не копия файла поверх: в цели уже лежат живые входы и свой
consumer_account. Строки сопоставляются по природному ключу (уникальные
ограничения), а не по id — номера в двух файлах выдавались независимо; ссылки
детей переписываются на новые номера родителей. Совпавшая строка не копируется:
побеждает целевая (в ней живое состояние — приоритеты пула, аренда), а
расхождение по колонкам перечисляется, а не глотается. Строка, чей родитель
не нашёлся в источнике вовсе (в боевом файле такая была — способ входа для
удалённого сервиса), помечается broken: перенос отказывается, пока человек не
скажет --skip-broken, и тогда она остаётся в старом файле. Источник не
удаляется — он уводится в registry.db.merged-<время>.bak.
Профиль под гонкой: «default» получается ОДИН
Профиль — это чей набор сессий. Ссылаются на него по uid, а называют
именем («рабочий», «тест»), поэтому имя менять можно свободно и уникальным оно
быть не обязано: двух «тестовых» профилей достаточно, чтобы уникальность
начала мешать. Неоднозначное имя даёт честный отказ (AmbiguousProfileName), а
не «первый попавшийся» — взять наугад значит записать сессию не тому аккаунту.
Но профиль default человек не заводит: его заводит кит, отвечая на первый
resolve("default"). Пока номер брался случайным, четверо на пустой базе (навык
в одном окне, gateway в другом, живой прогон в третьем) видели «такого имени
нет» каждый — и заводили свой. С этой секунды любой следующий
resolve("default") отказывал всем потребителям сразу, навсегда, до ручного
переименования. Отказ был формально верен и практически невыносим: выбрать между
двумя профилями, которых он не заводил, человек не может.
С 0.5.26 гонку разрешает та, кто о ней и так знает, — сама база. Номер
автозаведённого профиля не бросается кубиком, а выводится из пары «тенант +
имя» (uuid5): все спорщики предлагают один и тот же uid, а он у профиля уже
уникален в схеме — строку впишет ровно один, остальные прочитают победителя.
Уникального ключа (tenant, name) при этом не появилось (он запретил бы
законный случай), новой колонки и второй двери миграции — тоже: ключ, который всё
решает, в схеме уже стоял. Стережёт это тест из четырёх процессов на одном
файле.
Хвост с переименованием разобран чтением, а не догадкой: отказ базы означает либо «меня опередили», либо «номер занят уже переименованным профилем» — после отказа читается имя, и во втором случае заводится обычный случайный профиль.
Прогон не смеет попасть в живое хранилище. Раз умолчание ведёт в
~/.sessions, снятиеACCOUNTPOOL_*в тестах перестало быть защитой — оно как раз и открывает дорогу к умолчанию. Набор поэтому подменяетSESSIONS_HOMEна временный каталог, а не снимает его, и рядом стоит сторож, который краснеет, если умолчание прогона снова совпадёт с боевым файлом.
Потеря ключа не уничтожает входы
В хранилище лежат живые входы — соцсети и банки, — и документ каждого запечатан
общим ключом (LIBRARYKIT_MASTER_KEY / keyring). Пока ключ на месте, всё
обычно. Вопрос в том, что происходит, когда ключ сменили или потеряли.
Было так: Fernet бросал InvalidToken, отказ проглатывался, строка
разбиралась как открытый JSON, разбор не удавался — и наверх уходил пустой
словарь. Пустой словарь читается вызывающим ровно как «входа не было»: навык
идёт логиниться заново, и первая же запись накрывает запечатанный документ
новым. Прежний шифротекст стирается — и ключ, найденный назавтра, открывать
уже нечего. Ни ошибки, ни следа в логе.
Стало — три исхода вместо двух:
| что в базе | ответ |
|---|---|
| записи нет | пусто (законное «сессии нет») |
| открывается нашим ключом, либо это открытый JSON старше шифрования | документ |
| не открывается | SessionDocumentUnreadable — названный отказ |
Отказ несёт улику, а не догадку: Fernet-токен опознаётся по байту версии
0x80 и длине, поэтому «запечатано ДРУГИМ ключом» отличается от «здесь лежал
открытый JSON». Названы адрес входа, масштаб («тем же ключом закрыто входов: N
из M» — поимённо) и лечение.
Запись поверх нерасшифрованного запрещена. save_state и replace_state
читают перед записью и падают тем же отказом — то есть уничтожить документ
случайно больше нечем. Дверь на случай безвозвратно потерянного ключа одна и
названа: replace_state(..., overwrite_unreadable=True). Без неё потерянный ключ
запрещал бы вход навсегда.
Предупреждение приходит ДО того, как станет поздно. Смена ключа не рвёт ничего в тот же миг — документы лежат целыми, просто перестают открываться. Беда наступает первой записью, и между этими двумя моментами есть окно:
accountpool storage keycheck
# ключ НЕ открывает 3 из 14 входов
# НЕ ОТКРЫВАЕТСЯ: db://local/рабочий#a1b2c3d4/gemini/live/me@x
Команда ничего не пишет и не чинит — только читает и отвечает.
Аренда на минт: двое не логинятся одновременно
У Google одна сессия на аккаунт, и второй одновременный вход инвалидирует первый. Это не теория — это механизм, которым живые аккаунты и умирали.
Примитив аренды в хранилище был давно, но им не пользовался никто: ни одного
вызова во всём ките. Дверь называется mint_lease и держит DCL single-flight —
тот же приём, что у rotation.RefreshManager для OAuth, только замок общий
(строка в базе), а не файл:
async with store.mint_lease(ref, owner=mint_owner("gateway")) as аренда:
if аренда.ready is not None:
state = аренда.ready # сосед уже выпустил — берём ГОТОВОЕ
else:
state = await войти() # минтим мы, аренда наша
await store.save_state(ref, state)
- второй не минтит — он ждёт и получает результат первого по улике (версия документа выросла), а не по таймеру;
- не дождался — названный отказ
MintLeaseBusyс именем держателя, а не тихий второй вход; - упавший не держит аккаунт вечно: у аренды есть срок, и по улике
вышедшего срока её перехватывает следующий, без человека. Долгий минт (ручной
вход с 2FA) продлевает аренду сам —
await аренда.renew(); - два слоя:
asyncio.Lockна адрес внутри процесса (повторный захват тем же именем аренду продлевает — значит, одного имени мало) и аренда в базе между процессами и хостами. Имя владельца различающее:mint_owner()= хост/pid/метка.
Чтение и обычная запись ротации аренду не берут: там уже работает сверка версии со слиянием, а замок на каждый запрос превратил бы норму (два параллельных запроса) в аварию.
Граница с
browserkit— своего не пишем. Здесь аренда сессии: кто вправе выпустить новый вход в общем хранилище. Аренда процессов браузера —browserkit.BrowserLease(замер процессов до старта, разница после, добивание переживших, опознание нашего процесса по каталогу профиля:--user-data-dirу Chromium и-profileу CamouFox). Складываются они так: взять аренду сессии (здесь) → поднять движок подBrowserLease(там) → записать сессию → отдать аренду (здесь).
Снимает аренду только тот, кто её взял
Условие целиком в WHERE: адрес и владелец наш и срок не вышел — одна
операция базы, снять чужое физически нечем. Раньше строка читалась, владелец
сверялся в питоне, и только потом шла запись; срока в сверке не было вовсе —
просроченная аренда с нашим именем снималась как своя, хотя её в тот же миг
законно берёт сосед.
Исходов снова три: снял / не моя (держит названный, до какого времени) /
не знаю (записи по адресу нет). Ни один ничего не отнимает. Отказ называется
всегда — предупреждением в лог; strict=True поднимает LeaseRefused
исключением (по умолчанию нет: аренду отдают в finally, и исключение оттуда
затёрло бы настоящую ошибку минта).
Мёртвый владелец снять свою аренду уже не может — поэтому есть дверь для сироты,
break_lease(reason=...). Она идёт по улике «срок вышел» и живую аренду не
трогает: «сниму на всякий случай» — это и есть та болезнь.
Статус
Ядро стабильно: статус-машина аккаунта, quota/подписки, rate-limit + circuit-breaker,
OAuth-ротация (single-flight), egress-пул, import/export, AccountService-фасад + CLI.
Провайдер-плагины подключаются через entry-points accountpoolkit.providers.
Metadata
Release files for s-accountpoolkit 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| s_accountpoolkit-0.6.0.tar.gz | 891.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| s_accountpoolkit-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.4 MB
Release files / s_accountpoolkit-0.6.0.tar.gz
| Download URL | s_accountpoolkit-0.6.0.tar.gz |
|---|---|
| Size | 891.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7378bdbe60f0a01a25bcb40778ea6978739edf8793d481b5b32c476ca09bde4e
|
|
BLAKE2b-256 checksum How to use checksums |
513757e0b4b94cd5712444072db1fcf0ee21cb2cc10c8ac8e0556e2b569e1191
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / s_accountpoolkit-0.6.0-py3-none-any.whl
| Download URL | s_accountpoolkit-0.6.0-py3-none-any.whl |
|---|---|
| Size | 535.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e062fcf9424d018e5c2968f8ad113a68e20d48050eb1b0003a709d90cdd02ab2
|
|
BLAKE2b-256 checksum How to use checksums |
3d0f125a761d222ab0135d37ec0b4d0c4c1ec0797c69efaf771372efe089810f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|