funora 0.0.1.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_refund.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Возврат средств покупателю: адрес и разбор ответа.
|
|
2
|
+
|
|
3
|
+
САМАЯ ОПАСНАЯ ИЗ НАПИСАННЫХ ОПЕРАЦИЙ. Деньги уходят покупателю, и площадка не
|
|
4
|
+
предлагает вернуть их обратно ничем. «Попробовать и посмотреть» здесь стоит
|
|
5
|
+
настоящих денег продавца.
|
|
6
|
+
|
|
7
|
+
ЧТО НАБЛЮДЕНО НАМИ, А ЧТО НЕТ. Наблюдён ЗАПРОС - целиком: адрес в атрибуте
|
|
8
|
+
формы, оба поля в ней же, и признак того, что площадка возврат по этому заказу
|
|
9
|
+
предлагает.
|
|
10
|
+
|
|
11
|
+
НЕ НАБЛЮДЁН ОТВЕТ. Что в нём приходит, известно от независимой реализации того
|
|
12
|
+
же протокола: объект с признаком отказа error и сообщением msg.
|
|
13
|
+
|
|
14
|
+
ОТСЮДА ОТКАЗ ЧИТАТЬ ОТВЕТ БЕЗ ПРИЗНАКА. Площадка отвечает признаком ОТКАЗА, а не
|
|
15
|
+
успеха; отсутствие поля означает не успех, а непонятный ответ.
|
|
16
|
+
|
|
17
|
+
У поднятия то же правило стоило суток ожидания при ошибке. Здесь оно стоит денег,
|
|
18
|
+
о судьбе которых вызывающий не узнает, - и потому отказ здесь громче.
|
|
19
|
+
|
|
20
|
+
СУММЫ ВОЗВРАТА НЕТ НИГДЕ - ни в запросе, ни в ответе. Сколько именно вернулось,
|
|
21
|
+
вызывающий узнаёт только чтением заказа.
|
|
22
|
+
|
|
23
|
+
Наблюдено нами 31.08.2026: order.v9.logged.ru, форма возврата и её поля.
|
|
24
|
+
Известно от FunPayAPI (FunPayCardinal, account.py, refund): состав ответа.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from typing import Any, Final
|
|
32
|
+
|
|
33
|
+
from .errors import ProtocolChangedError
|
|
34
|
+
|
|
35
|
+
__all__ = ["RefundResult", "parse_refund", "REFUND_PATH"]
|
|
36
|
+
|
|
37
|
+
#: Адрес возврата. Наблюдён НАМИ - в атрибуте action формы на странице заказа.
|
|
38
|
+
REFUND_PATH: Final[str] = "/orders/refund"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class RefundResult:
|
|
43
|
+
"""Исход возврата средств покупателю.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
refunded (bool): Состоялся ли возврат. Читается ОТРИЦАНИЕМ признака
|
|
47
|
+
отказа: площадка отвечает признаком отказа, а не успеха.
|
|
48
|
+
message (str): Сообщение площадки человеку, как есть. Не разбирается:
|
|
49
|
+
это текст на локали интерфейса, а причина здесь про деньги.
|
|
50
|
+
order_id (str): Заказ, по которому просили возврат.
|
|
51
|
+
observed_at (datetime): Момент получения ответа.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
refunded: bool
|
|
55
|
+
message: str
|
|
56
|
+
order_id: str
|
|
57
|
+
observed_at: datetime
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_refund(payload: Any, *, order_id: str, observed_at: datetime) -> RefundResult:
|
|
61
|
+
"""Разбирает ответ площадки на возврат.
|
|
62
|
+
|
|
63
|
+
ПРИЗНАК ОТКАЗА ОБЯЗАТЕЛЕН. Без него неизвестно, ушли деньги или нет, а
|
|
64
|
+
повторить, чтобы выяснить, нельзя: повтор здесь - второй возврат.
|
|
65
|
+
|
|
66
|
+
Аргументы:
|
|
67
|
+
payload (Any): Разобранное тело ответа.
|
|
68
|
+
order_id (str): Заказ, по которому просили возврат.
|
|
69
|
+
observed_at (datetime): Момент получения.
|
|
70
|
+
|
|
71
|
+
Возвращает:
|
|
72
|
+
RefundResult: Исход.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ProtocolChangedError: Если в ответе нет признака отказа.
|
|
76
|
+
"""
|
|
77
|
+
if not isinstance(payload, dict):
|
|
78
|
+
raise ProtocolChangedError(
|
|
79
|
+
f"ответ на возврат не объект, а {type(payload).__name__}. Что "
|
|
80
|
+
f"случилось с деньгами по заказу {order_id} - неизвестно; прочитайте "
|
|
81
|
+
"заказ и посмотрите"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
error = payload.get("error")
|
|
85
|
+
# Логическое требуется строго. Истина в Python - это единица, и error=1
|
|
86
|
+
# прочиталось бы как отказ, а error=0 как успех, ни разу не будучи
|
|
87
|
+
# логическим. У денег такая подмена стоит дороже всего.
|
|
88
|
+
if not isinstance(error, bool):
|
|
89
|
+
raise ProtocolChangedError(
|
|
90
|
+
f"в ответе на возврат по заказу {order_id} нет признака отказа error. "
|
|
91
|
+
"Площадка отвечает признаком ОТКАЗА, а не успеха, и без него исход "
|
|
92
|
+
"неизвестен. Деньги МОГЛИ уйти покупателю: прочитайте заказ и "
|
|
93
|
+
"посмотрите, а повторять не надо - повтор здесь второй возврат"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
raw_message = payload.get("msg")
|
|
97
|
+
return RefundResult(
|
|
98
|
+
refunded=not error,
|
|
99
|
+
message=raw_message if isinstance(raw_message, str) else "",
|
|
100
|
+
order_id=order_id,
|
|
101
|
+
observed_at=observed_at,
|
|
102
|
+
)
|
funora/_result.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Общие понятия разбора: полнота, повреждения, сбор строк.
|
|
2
|
+
|
|
3
|
+
Файл появился после разбора, нашедшего одну и ту же дыру во всех трёх
|
|
4
|
+
разборщиках сразу. Причина была общая: каждый брал первый попавшийся контейнер
|
|
5
|
+
строк и считал внутри него оба счётчика, поэтому строки во втором контейнере
|
|
6
|
+
исчезали молча - и механизм двух счётчиков, написанный ровно против такого
|
|
7
|
+
исчезновения, с ними соглашался.
|
|
8
|
+
|
|
9
|
+
Пока эти понятия жили в разборе заказов, а два других разборщика их оттуда
|
|
10
|
+
импортировали, чинить приходилось бы в трёх местах. Теперь место одно.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
|
|
18
|
+
from selectolax.parser import HTMLParser, Node
|
|
19
|
+
|
|
20
|
+
__all__ = ["Severity", "Completeness", "Defect", "RowSet", "collect_rows"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Severity(StrEnum):
|
|
24
|
+
"""Уровень, на котором обнаружено повреждение разбора.
|
|
25
|
+
|
|
26
|
+
Уровни различают, что именно потеряно, потому что решения по ним разные.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
#: Пострадала страница целиком. Записям доверять нельзя.
|
|
30
|
+
PAGE = "page"
|
|
31
|
+
|
|
32
|
+
#: Пострадала одна строка. Она отброшена, остальные целы.
|
|
33
|
+
ROW = "row"
|
|
34
|
+
|
|
35
|
+
#: Пострадало одно поле одной строки. Строка сохранена.
|
|
36
|
+
FIELD = "field"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Completeness(StrEnum):
|
|
40
|
+
"""Полнота прочитанного.
|
|
41
|
+
|
|
42
|
+
Словарь взят из спецификации и не расширяется реализацией: значение уходит
|
|
43
|
+
в решение вызывающего, и лишнее значение здесь означает, что шесть SDK
|
|
44
|
+
ответят на один вопрос по-разному.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
#: Всё разобрано, повреждений нет.
|
|
48
|
+
COMPLETE = "complete"
|
|
49
|
+
|
|
50
|
+
#: Часть данных потеряна. Что именно - в перечне повреждений.
|
|
51
|
+
PARTIAL = "partial"
|
|
52
|
+
|
|
53
|
+
#: Полноту установить не удалось.
|
|
54
|
+
UNKNOWN = "unknown"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class Defect:
|
|
59
|
+
"""Повреждение, обнаруженное при разборе.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
severity (Severity): Уровень, на котором обнаружено.
|
|
63
|
+
code (str): Машиночитаемый код, например ``row_selector_undercount``.
|
|
64
|
+
detail (str): Пояснение для человека. Содержимого страницы не содержит.
|
|
65
|
+
row_index (int | None): Номер строки при severity ROW и FIELD.
|
|
66
|
+
field_name (str | None): Имя поля при severity FIELD.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
severity: Severity
|
|
70
|
+
code: str
|
|
71
|
+
detail: str
|
|
72
|
+
row_index: int | None = None
|
|
73
|
+
field_name: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class RowSet:
|
|
78
|
+
"""Строки, найденные на странице, вместе с обнаруженными расхождениями.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
rows (tuple[Node, ...]): Узлы строк в порядке появления.
|
|
82
|
+
children (int): Сколько прямых потомков у всех контейнеров вместе.
|
|
83
|
+
containers (int): Сколько контейнеров строк нашлось.
|
|
84
|
+
defects (tuple[Defect, ...]): Расхождения счётчиков.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
rows: tuple[Node, ...]
|
|
88
|
+
children: int
|
|
89
|
+
containers: int
|
|
90
|
+
defects: tuple[Defect, ...]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def collect_rows(tree: HTMLParser, container: str, row: str) -> RowSet:
|
|
94
|
+
"""Собирает строки по всем контейнерам и сверяет три независимых счёта.
|
|
95
|
+
|
|
96
|
+
Счётчиков три, и каждый ловит своё.
|
|
97
|
+
|
|
98
|
+
Совпадения селектора строки внутри контейнеров - основной счёт.
|
|
99
|
+
|
|
100
|
+
Прямые потомки контейнеров - счёт, не зависящий от класса строки. Он ловит
|
|
101
|
+
самый вероятный вид изменения вёрстки: переименование класса при живом
|
|
102
|
+
контейнере, дающее иначе пустой список, неотличимый от «записей нет».
|
|
103
|
+
|
|
104
|
+
Совпадения селектора строки по всему документу - счёт, не зависящий от
|
|
105
|
+
контейнера. Он ловит то, чего не ловят первые два: строки, оказавшиеся во
|
|
106
|
+
втором контейнере либо вне контейнера вовсе. Прежний разбор брал первый
|
|
107
|
+
попавшийся контейнер, и оба счётчика соглашались друг с другом внутри него,
|
|
108
|
+
пока часть записей исчезала молча - при полноте complete и нуле повреждений.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
tree (HTMLParser): Разобранный документ.
|
|
112
|
+
container (str): Селектор контейнера строк.
|
|
113
|
+
row (str): Селектор строки.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
RowSet: Найденные строки, счётчики и расхождения.
|
|
117
|
+
"""
|
|
118
|
+
containers = tree.css(container)
|
|
119
|
+
rows: list[Node] = []
|
|
120
|
+
children = 0
|
|
121
|
+
|
|
122
|
+
for node in containers:
|
|
123
|
+
rows.extend(node.css(row))
|
|
124
|
+
children += len([child for child in node.iter() if child.tag != "-text"])
|
|
125
|
+
|
|
126
|
+
defects: list[Defect] = []
|
|
127
|
+
|
|
128
|
+
if len(rows) != children:
|
|
129
|
+
defects.append(
|
|
130
|
+
Defect(
|
|
131
|
+
severity=Severity.PAGE,
|
|
132
|
+
code="row_selector_undercount",
|
|
133
|
+
detail=(
|
|
134
|
+
f"селектор строки нашёл {len(rows)}, а прямых потомков у контейнеров {children}"
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
in_document = len(tree.css(row))
|
|
140
|
+
if in_document != len(rows):
|
|
141
|
+
defects.append(
|
|
142
|
+
Defect(
|
|
143
|
+
severity=Severity.PAGE,
|
|
144
|
+
code="rows_outside_container",
|
|
145
|
+
detail=(
|
|
146
|
+
f"по документу строк {in_document}, а внутри контейнеров "
|
|
147
|
+
f"{len(rows)}: часть записей лежит вне разбираемой области"
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return RowSet(
|
|
153
|
+
rows=tuple(rows),
|
|
154
|
+
children=children,
|
|
155
|
+
containers=len(containers),
|
|
156
|
+
defects=tuple(defects),
|
|
157
|
+
)
|
funora/_retry.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Решение о повторе запроса.
|
|
2
|
+
|
|
3
|
+
Модуль чистый и не спит. Он отвечает на вопрос «повторять ли и через сколько», а
|
|
4
|
+
ждать обязан вызывающий. Разделение нужно затем, что решение проверяется
|
|
5
|
+
тестами, а сон - нет: планировщик, который спит внутри, приходится проверять
|
|
6
|
+
часами вместо секунд, и в итоге не проверяют вовсе.
|
|
7
|
+
|
|
8
|
+
Условий для повтора три, и каждое отсекает свой вид беды.
|
|
9
|
+
|
|
10
|
+
Ошибка обязана быть помечена повторяемой. Это свойство класса ошибки, взятое из
|
|
11
|
+
спецификации: соединение не установилось - запрос не дошёл, повторять безопасно.
|
|
12
|
+
|
|
13
|
+
Решение не должно быть принято непроверенной сигнатурой. Признак ``provisional``
|
|
14
|
+
означает, что страницу такого вида никто не видел и вердикт вынесен по догадке
|
|
15
|
+
о тексте. Повторяться против догадки нельзя: если догадка неверна, клиент
|
|
16
|
+
долбится в исправную страницу, а если верна - подтверждает подозрение площадки.
|
|
17
|
+
|
|
18
|
+
Операция обязана быть безопасной. Повтор небезопасной операции может выполнить
|
|
19
|
+
её дважды, и списанные деньги вторым разом не возвращаются.
|
|
20
|
+
|
|
21
|
+
Разброс задержки полный, а не половинный, и это не мелочь. При нескольких
|
|
22
|
+
клиентах на одном адресе детерминированное отступление синхронизирует их, и
|
|
23
|
+
площадка видит не шесть вежливых клиентов, а один невежливый.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import random as _random
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Final
|
|
32
|
+
|
|
33
|
+
from ._verdicts import PROVISIONAL_ATTR
|
|
34
|
+
from .errors import ConfigurationError, FunoraError
|
|
35
|
+
from .operations import Safety
|
|
36
|
+
from .retry import (
|
|
37
|
+
DECISION_MATRIX,
|
|
38
|
+
FALLBACK_POLICY,
|
|
39
|
+
GLOBAL_MAX_ATTEMPTS,
|
|
40
|
+
RETRY_POLICIES,
|
|
41
|
+
RetryDecision,
|
|
42
|
+
RetryPolicy,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = ["RETRY_REASON_ATTR", "decide", "Safety", "Attempt", "policy_for", "plan_attempt"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: Имя признака, которым причина решения о повторе переносится в исключение.
|
|
49
|
+
#:
|
|
50
|
+
#: Заведено потому, что решение reconcile_first снаружи было НЕОТЛИЧИМО от
|
|
51
|
+
#: прочих отказов в повторе. На чтениях это безвредно: не повторили и не
|
|
52
|
+
#: повторили. На первой же операции записи это ровно тот сигнал, ради которого
|
|
53
|
+
#: матрица решений и заведена: вызывающему полагается СВЕРИТЬСЯ, а не считать
|
|
54
|
+
#: отправку неудавшейся. Отправка могла и пройти - неоднозначным был исход, а не
|
|
55
|
+
#: результат.
|
|
56
|
+
#:
|
|
57
|
+
#: Признак ставится на всякий отказ в повторе, а не только на reconcile_first:
|
|
58
|
+
#: правило «переносим одну причину из пяти» разошлось бы с матрицей молча, стоит
|
|
59
|
+
#: добавить в неё шестую.
|
|
60
|
+
RETRY_REASON_ATTR = "retry_reason"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class Attempt:
|
|
65
|
+
"""Решение о следующей попытке.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
retry (bool): Повторять ли запрос.
|
|
69
|
+
delay_ms (int): Через сколько миллисекунд. Ноль, если повтора не будет.
|
|
70
|
+
reason (str): Машиночитаемая причина решения. Уходит в журнал, поэтому
|
|
71
|
+
по ней должно быть видно, какое из условий не выполнилось.
|
|
72
|
+
policy (RetryPolicy): Политика, по которой принято решение.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
retry: bool
|
|
76
|
+
delay_ms: int
|
|
77
|
+
reason: str
|
|
78
|
+
policy: RetryPolicy
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def policy_for(error: FunoraError) -> RetryPolicy:
|
|
82
|
+
"""Подбирает политику повторов для ошибки.
|
|
83
|
+
|
|
84
|
+
Поиск идёт от самого точного к общему: сначала по устойчивому идентификатору
|
|
85
|
+
самой ошибки, затем по идентификаторам её предков, затем запасная. Такой
|
|
86
|
+
порядок нужен, чтобы добавление подтипа не меняло поведение молча: подтип без
|
|
87
|
+
собственной политики наследует политику родителя, а не самую щедрую.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
error (FunoraError): Экземпляр ошибки.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
RetryPolicy: Политика. Запасная, если ни один предок не описан.
|
|
94
|
+
"""
|
|
95
|
+
for cls in type(error).__mro__:
|
|
96
|
+
stable_id = getattr(cls, "stable_id", None)
|
|
97
|
+
if stable_id in RETRY_POLICIES:
|
|
98
|
+
return RETRY_POLICIES[stable_id]
|
|
99
|
+
return FALLBACK_POLICY
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def decide(error: Exception, safety: Safety) -> RetryDecision:
|
|
103
|
+
"""Находит строку матрицы, которая решает о повторе.
|
|
104
|
+
|
|
105
|
+
Матрица - пересечение двух вещей: класса ошибки и безопасности операции.
|
|
106
|
+
Реализация долго сводила её к одному условию «повторяем только чтения»: это
|
|
107
|
+
строже контракта и потому безопасно, но расходится - второй SDK на той же
|
|
108
|
+
трассе поступит иначе, и разойдутся они молча.
|
|
109
|
+
|
|
110
|
+
Строки читаются сверху вниз, первая подошедшая решает. Порядок значим и
|
|
111
|
+
задан спецификацией: первая строка отсекает неповторяемый класс ошибки
|
|
112
|
+
независимо от операции.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
error (Exception): Полученная ошибка.
|
|
116
|
+
safety (Safety): Безопасность операции при повторе.
|
|
117
|
+
idempotency_key (str | None): Ключ идемпотентности, если вызывающий его
|
|
118
|
+
вычислил. Нужен идемпотентной операции: без него матрица считает её
|
|
119
|
+
небезопасной.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
RetryDecision: Что матрица говорит о повторе.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ConfigurationError: Если ни одна строка не подошла. Матрица обязана быть
|
|
126
|
+
полной: сочетание без строки означает, что реализации решат сами.
|
|
127
|
+
"""
|
|
128
|
+
retryable = bool(getattr(type(error), "retryable", False))
|
|
129
|
+
effects = bool(getattr(type(error), "side_effects_possible", False))
|
|
130
|
+
|
|
131
|
+
for row_retryable, row_safety, row_effects, result in DECISION_MATRIX:
|
|
132
|
+
if row_retryable is not retryable:
|
|
133
|
+
continue
|
|
134
|
+
if row_safety is not None and row_safety != safety.value:
|
|
135
|
+
continue
|
|
136
|
+
if row_effects is not None and row_effects is not effects:
|
|
137
|
+
continue
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
raise ConfigurationError(
|
|
141
|
+
f"матрица решения о повторе не покрывает сочетание: повторяемость "
|
|
142
|
+
f"{retryable}, безопасность {safety.value}, побочный эффект {effects}. "
|
|
143
|
+
"Неполная матрица означает, что реализации решат сами - и разойдутся"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def plan_attempt(
|
|
148
|
+
error: FunoraError,
|
|
149
|
+
*,
|
|
150
|
+
attempt: int,
|
|
151
|
+
safety: Safety = Safety.SAFE,
|
|
152
|
+
idempotency_key: str | None = None,
|
|
153
|
+
retry_after_ms: int | None = None,
|
|
154
|
+
rand: Callable[[], float] = _random.random,
|
|
155
|
+
) -> Attempt:
|
|
156
|
+
"""Решает, повторять ли запрос после ошибки.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
error (FunoraError): Ошибка, из-за которой попытка не удалась.
|
|
160
|
+
attempt (int): Номер завершившейся попытки, начиная с единицы.
|
|
161
|
+
safety (Safety): Безопасность операции при повторе.
|
|
162
|
+
retry_after_ms (int | None): Значение заголовка Retry-After в
|
|
163
|
+
миллисекундах, если площадка его прислала.
|
|
164
|
+
rand (Callable[[], float]): Источник случайности для разброса. Передаётся
|
|
165
|
+
снаружи, чтобы решение можно было проверить: со случайностью внутри
|
|
166
|
+
проверить нечего.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Attempt: Решение вместе с причиной и применённой политикой.
|
|
170
|
+
"""
|
|
171
|
+
policy = policy_for(error)
|
|
172
|
+
|
|
173
|
+
if not type(error).retryable:
|
|
174
|
+
return Attempt(False, 0, "error_not_retryable", policy)
|
|
175
|
+
|
|
176
|
+
if getattr(error, PROVISIONAL_ATTR, False):
|
|
177
|
+
return Attempt(False, 0, "verdict_provisional", policy)
|
|
178
|
+
|
|
179
|
+
decision = decide(error, safety)
|
|
180
|
+
if decision is RetryDecision.NEVER:
|
|
181
|
+
return Attempt(False, 0, "error_not_retryable", policy)
|
|
182
|
+
if decision is RetryDecision.RECONCILE_FIRST:
|
|
183
|
+
# Повтор запрещён не потому, что бесполезен, а потому, что опасен:
|
|
184
|
+
# операция небезопасна, и ошибка допускает побочный эффект. Прежде чем
|
|
185
|
+
# повторять, надо прочитать фактическое положение дел. Это тот случай,
|
|
186
|
+
# когда покупатель получает второе сообщение или второй ключ автовыдачи.
|
|
187
|
+
return Attempt(False, 0, "reconcile_first", policy)
|
|
188
|
+
if decision is RetryDecision.ALLOWED_WITH_KEY and idempotency_key is None:
|
|
189
|
+
# Без ключа идемпотентная операция ведёт себя как небезопасная - так
|
|
190
|
+
# сказано в самой матрице. Отказ здесь честнее повтора: ключ не
|
|
191
|
+
# придумывается реализацией, его вычисляет вызывающий по составу,
|
|
192
|
+
# объявленному у операции.
|
|
193
|
+
return Attempt(False, 0, "idempotency_key_required", policy)
|
|
194
|
+
|
|
195
|
+
limit = min(policy.max_attempts, GLOBAL_MAX_ATTEMPTS)
|
|
196
|
+
if attempt >= limit:
|
|
197
|
+
return Attempt(False, 0, f"attempts_exhausted:{limit}", policy)
|
|
198
|
+
|
|
199
|
+
if retry_after_ms is not None and policy.respect_retry_after:
|
|
200
|
+
# Верхняя граница обязательна: битое или враждебное значение вида
|
|
201
|
+
# 86400 секунд вешает цикл опроса на сутки, и снаружи это неотличимо
|
|
202
|
+
# от зависшего процесса.
|
|
203
|
+
delay = min(retry_after_ms, policy.max_retry_after_ms)
|
|
204
|
+
return Attempt(True, delay, "retry_after_header", policy)
|
|
205
|
+
|
|
206
|
+
ideal = policy.base_ms * (policy.multiplier ** (attempt - 1))
|
|
207
|
+
capped = min(int(ideal), policy.cap_ms)
|
|
208
|
+
delay = int(capped * rand()) if policy.jitter == "full" else capped
|
|
209
|
+
return Attempt(True, delay, "backoff", policy)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
#: Значение, которым обозначается отсутствие ожидания.
|
|
213
|
+
NO_DELAY: Final[int] = 0
|
funora/_review_write.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Написание и снятие отзыва: адреса и разбор ответа.
|
|
2
|
+
|
|
3
|
+
ОДНА ТОЧКА НА ТРИ ДЕЙСТВИЯ. Написать отзыв, ПРАВИТЬ уже написанный и ответить на
|
|
4
|
+
чужой - всё это один адрес; различает их сама площадка по тому, кто автор и есть
|
|
5
|
+
ли оценка. Отдельного адреса у правки нет, и потому повтор не создаёт второго
|
|
6
|
+
отзыва, а переписывает тот же самый.
|
|
7
|
+
|
|
8
|
+
УСПЕХ УСТАНАВЛИВАЕТСЯ ПОЛОЖИТЕЛЬНЫМ ПРИЗНАКОМ, и это главное отличие нашего
|
|
9
|
+
разбора от стороннего.
|
|
10
|
+
|
|
11
|
+
Независимая реализация того же протокола объявляет успехом отсутствие отказа:
|
|
12
|
+
смотрит код ответа и в тело не заглядывает. Тело же несёт ПЕРЕРИСОВАННЫЙ виджет
|
|
13
|
+
отзыва - готовый положительный признак, который остаётся только прочитать.
|
|
14
|
+
|
|
15
|
+
Читаем мы его тем же разбором, что и отзыв на странице заказа: тот же класс
|
|
16
|
+
.review-container, тот же атрибут data-rating. Разбор наш и наблюдён нами;
|
|
17
|
+
чужое здесь - только то, что в ответе лежит именно он.
|
|
18
|
+
|
|
19
|
+
Наблюдено нами: атрибуты data-order и data-author на странице заказа.
|
|
20
|
+
Известно от FunPayAPI (FunPayCardinal, account.py): адреса, имена полей запроса
|
|
21
|
+
и ключи ответа.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Any, Final
|
|
29
|
+
|
|
30
|
+
from ._observed import Observed
|
|
31
|
+
from .errors import ProtocolChangedError
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ReviewResult",
|
|
35
|
+
"parse_review_response",
|
|
36
|
+
"REVIEW_PATH",
|
|
37
|
+
"REVIEW_REMOVE_PATH",
|
|
38
|
+
"RATING_MIN",
|
|
39
|
+
"RATING_MAX",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
#: Адрес написания отзыва. Известен от сторонней реализации.
|
|
43
|
+
REVIEW_PATH: Final[str] = "/orders/review"
|
|
44
|
+
|
|
45
|
+
#: Адрес снятия отзыва. Оттуда же.
|
|
46
|
+
REVIEW_REMOVE_PATH: Final[str] = "/orders/reviewDelete"
|
|
47
|
+
|
|
48
|
+
#: Наименьшая наблюдённая оценка.
|
|
49
|
+
RATING_MIN: Final[int] = 1
|
|
50
|
+
|
|
51
|
+
#: Наибольшая наблюдённая оценка.
|
|
52
|
+
#:
|
|
53
|
+
#: Границы стоят здесь, а не литералами в проверке: они наблюдение, и менять их
|
|
54
|
+
#: можно только новым наблюдением. Что площадка сделает с шестёркой либо с нулём,
|
|
55
|
+
#: никто не видел, и отправлять непроверенное мы не станем.
|
|
56
|
+
RATING_MAX: Final[int] = 5
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class ReviewResult:
|
|
61
|
+
"""Исход написания либо снятия отзыва.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
applied (bool): Удалось ли ПОДТВЕРДИТЬ исход положительным признаком.
|
|
65
|
+
|
|
66
|
+
Ложь означает не «не получилось», а «подтвердить не удалось»:
|
|
67
|
+
запрос ушёл, ответ пришёл, а виджет в нём не разобрался либо оценка
|
|
68
|
+
в нём не та, что отправляли. Различие существенное - первое
|
|
69
|
+
разрешает повторить, второе требует ПОСМОТРЕТЬ.
|
|
70
|
+
message (str): Сообщение площадки человеку, как есть. Не разбирается:
|
|
71
|
+
это текст на локали интерфейса.
|
|
72
|
+
rating (Observed[int]): Оценка, прочитанная В ОТВЕТЕ, а не отправленная
|
|
73
|
+
нами. Её сверка с отправленной и даёт applied. Ноль означает
|
|
74
|
+
отсутствие отзыва - так же, как на странице заказа.
|
|
75
|
+
observed_at (datetime): Момент получения ответа.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
applied: bool
|
|
79
|
+
message: str
|
|
80
|
+
rating: Observed[int]
|
|
81
|
+
observed_at: datetime
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_review_response(
|
|
85
|
+
payload: Any, *, expected_rating: int | None, observed_at: datetime
|
|
86
|
+
) -> ReviewResult:
|
|
87
|
+
"""Разбирает ответ площадки на написание либо снятие отзыва.
|
|
88
|
+
|
|
89
|
+
ОЖИДАЕМАЯ ОЦЕНКА - ЭТО ТО, ЧЕМ СВЕРЯЮТ, а не то, что подставляют. Придёт в
|
|
90
|
+
ответе другая - applied станет ложью, и вызывающий узнает, что площадка
|
|
91
|
+
сделала не то, о чём просили.
|
|
92
|
+
|
|
93
|
+
Аргументы:
|
|
94
|
+
payload (Any): Разобранное тело ответа.
|
|
95
|
+
expected_rating (int | None): Отправленная оценка либо None при снятии,
|
|
96
|
+
где ожидается отсутствие отзыва.
|
|
97
|
+
observed_at (datetime): Момент получения.
|
|
98
|
+
|
|
99
|
+
Возвращает:
|
|
100
|
+
ReviewResult: Исход.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ProtocolChangedError: Если ответ непригоден для чтения вовсе.
|
|
104
|
+
"""
|
|
105
|
+
if not isinstance(payload, dict):
|
|
106
|
+
raise ProtocolChangedError(
|
|
107
|
+
f"ответ на отзыв не объект, а {type(payload).__name__}. Что случилось "
|
|
108
|
+
"с отзывом - неизвестно"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
raw_message = payload.get("msg")
|
|
112
|
+
message = raw_message if isinstance(raw_message, str) else ""
|
|
113
|
+
|
|
114
|
+
raw_content = payload.get("content")
|
|
115
|
+
if not isinstance(raw_content, str) or not raw_content.strip():
|
|
116
|
+
# Тела виджета нет - подтверждать нечем. Это НЕ отказ: запрос мог
|
|
117
|
+
# состояться, и объявлять неудачу так же неверно, как объявлять успех.
|
|
118
|
+
return ReviewResult(
|
|
119
|
+
applied=False,
|
|
120
|
+
message=message,
|
|
121
|
+
rating=Observed.missing("review_widget_absent_from_response"),
|
|
122
|
+
observed_at=observed_at,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Разбор тот же, что у отзыва на странице заказа: те же селекторы, то же
|
|
126
|
+
# чтение. Второй разбор для той же разметки разошёлся бы с первым молча.
|
|
127
|
+
from ._order import parse_review_block
|
|
128
|
+
|
|
129
|
+
rating, _author, _defects = parse_review_block(raw_content)
|
|
130
|
+
|
|
131
|
+
expected = 0 if expected_rating is None else expected_rating
|
|
132
|
+
applied = rating.or_none() == expected
|
|
133
|
+
return ReviewResult(
|
|
134
|
+
applied=applied,
|
|
135
|
+
message=message,
|
|
136
|
+
rating=rating,
|
|
137
|
+
observed_at=observed_at,
|
|
138
|
+
)
|