pushizo 0.1.0__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.
- pushizo/__init__.py +63 -0
- pushizo/_client.py +367 -0
- pushizo/_errors.py +130 -0
- pushizo/_generated/__init__.py +4 -0
- pushizo/_generated/models.py +10558 -0
- pushizo/_generated/resources.py +6594 -0
- pushizo/_identity.py +48 -0
- pushizo/_types.py +22 -0
- pushizo/_version.py +1 -0
- pushizo/_webhooks.py +110 -0
- pushizo/py.typed +0 -0
- pushizo-0.1.0.dist-info/METADATA +205 -0
- pushizo-0.1.0.dist-info/RECORD +16 -0
- pushizo-0.1.0.dist-info/WHEEL +5 -0
- pushizo-0.1.0.dist-info/licenses/LICENSE +21 -0
- pushizo-0.1.0.dist-info/top_level.txt +1 -0
pushizo/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Pushizo Python sunucu SDK'sı.
|
|
2
|
+
|
|
3
|
+
.. code-block:: python
|
|
4
|
+
|
|
5
|
+
from pushizo import Pushizo
|
|
6
|
+
|
|
7
|
+
hs = Pushizo(api_key=os.environ["HS_API_KEY"], app_id="app_…")
|
|
8
|
+
hs.notifications.create({"target": {"include_aliases": {"external_id": ["u1"]}}, "contents": {"en": "Shipped"}})
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._client import DEFAULT_BASE_URL, Pushizo
|
|
12
|
+
from ._errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConflictError,
|
|
15
|
+
ErrorCode,
|
|
16
|
+
PushizoError,
|
|
17
|
+
InvalidRequestError,
|
|
18
|
+
NetworkError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PermissionDeniedError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
ServerError,
|
|
23
|
+
WebhookSignatureError,
|
|
24
|
+
)
|
|
25
|
+
from ._generated import API_VERSION
|
|
26
|
+
from ._identity import identity_token
|
|
27
|
+
from ._types import FileInput, RequestOptions
|
|
28
|
+
from ._version import __version__
|
|
29
|
+
from ._webhooks import (
|
|
30
|
+
WEBHOOK_DEFAULT_TOLERANCE_SECONDS,
|
|
31
|
+
WEBHOOK_EVENT_ID_HEADER,
|
|
32
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
33
|
+
WebhookVerifyResult,
|
|
34
|
+
construct_webhook_events,
|
|
35
|
+
verify_webhook_signature,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"identity_token",
|
|
40
|
+
"API_VERSION",
|
|
41
|
+
"DEFAULT_BASE_URL",
|
|
42
|
+
"WEBHOOK_DEFAULT_TOLERANCE_SECONDS",
|
|
43
|
+
"WEBHOOK_EVENT_ID_HEADER",
|
|
44
|
+
"WEBHOOK_SIGNATURE_HEADER",
|
|
45
|
+
"AuthenticationError",
|
|
46
|
+
"ConflictError",
|
|
47
|
+
"ErrorCode",
|
|
48
|
+
"FileInput",
|
|
49
|
+
"Pushizo",
|
|
50
|
+
"PushizoError",
|
|
51
|
+
"InvalidRequestError",
|
|
52
|
+
"NetworkError",
|
|
53
|
+
"NotFoundError",
|
|
54
|
+
"PermissionDeniedError",
|
|
55
|
+
"RateLimitError",
|
|
56
|
+
"RequestOptions",
|
|
57
|
+
"ServerError",
|
|
58
|
+
"WebhookSignatureError",
|
|
59
|
+
"WebhookVerifyResult",
|
|
60
|
+
"__version__",
|
|
61
|
+
"construct_webhook_events",
|
|
62
|
+
"verify_webhook_signature",
|
|
63
|
+
]
|
pushizo/_client.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Pushizo HTTP istemcisi: kimlik doğrulama, yeniden deneme, idempotency ve sayfalama."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from email.utils import parsedate_to_datetime
|
|
11
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._errors import PushizoError, NetworkError, error_class_for_status
|
|
17
|
+
from ._generated.resources import GeneratedResources
|
|
18
|
+
from ._identity import identity_token
|
|
19
|
+
from ._types import FileInput, RequestOptions
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.pushizo.com"
|
|
23
|
+
DEFAULT_TIMEOUT = 30.0
|
|
24
|
+
DEFAULT_MAX_RETRIES = 3
|
|
25
|
+
_BACKOFF_SECONDS = (0.3, 1.0, 3.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Pushizo(GeneratedResources):
|
|
29
|
+
"""Pushizo sunucu SDK istemcisi.
|
|
30
|
+
|
|
31
|
+
.. code-block:: python
|
|
32
|
+
|
|
33
|
+
from pushizo import Pushizo
|
|
34
|
+
|
|
35
|
+
hs = Pushizo(api_key=os.environ["HS_API_KEY"], app_id="app_…")
|
|
36
|
+
hs.notifications.create(
|
|
37
|
+
{"target": {"include_aliases": {"external_id": [user_id]}}, "contents": {"en": "Shipped"}},
|
|
38
|
+
idempotency_key=f"order-{order_id}",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
Kaynaklar (``notifications``, ``users``, ``segments``, ``templates``, ``campaigns`` …; tam liste
|
|
42
|
+
``pushizo._generated.resources.RESOURCE_NAMES``) ``api/openapi.yaml``'dan üretilir.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: str,
|
|
48
|
+
*,
|
|
49
|
+
app_id: Optional[str] = None,
|
|
50
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
51
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
52
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
53
|
+
auto_idempotency_key: bool = True,
|
|
54
|
+
user_agent: Optional[str] = None,
|
|
55
|
+
http_client: Optional[httpx.Client] = None,
|
|
56
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""
|
|
59
|
+
:param api_key: ``hs_live_…`` API anahtarı (Bearer).
|
|
60
|
+
:param app_id: Çağrılarda ``app_id`` verilmezse kullanılacak varsayılan app.
|
|
61
|
+
:param base_url: API adresi; yerel geliştirmede ``http://localhost:4000``.
|
|
62
|
+
:param timeout: İstek zaman aşımı (saniye).
|
|
63
|
+
:param max_retries: 429/5xx ve ağ hatalarında en fazla yeniden deneme.
|
|
64
|
+
:param auto_idempotency_key: ``Idempotency-Key`` destekleyen çağrılarda anahtar verilmezse UUID üret.
|
|
65
|
+
:param http_client: Özel ``httpx.Client`` (proxy, TLS, test transport'u). Verilirse kapatılması çağırana aittir.
|
|
66
|
+
:param sleep: Yeniden deneme beklemesi (testlerde değiştirilebilir).
|
|
67
|
+
"""
|
|
68
|
+
if not api_key:
|
|
69
|
+
raise ValueError("Pushizo: api_key gerekli.")
|
|
70
|
+
self.api_key = api_key
|
|
71
|
+
self.app_id = app_id
|
|
72
|
+
self.base_url = base_url.rstrip("/")
|
|
73
|
+
self.timeout = timeout
|
|
74
|
+
self.max_retries = max_retries
|
|
75
|
+
self.auto_idempotency_key = auto_idempotency_key
|
|
76
|
+
self.user_agent = user_agent or f"pushizo-python/{__version__}"
|
|
77
|
+
self._owns_http = http_client is None
|
|
78
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
79
|
+
self._sleep = sleep
|
|
80
|
+
self._init_resources()
|
|
81
|
+
|
|
82
|
+
# -- yaşam döngüsü -------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
"""İstemcinin kendi oluşturduğu bağlantı havuzunu kapatır."""
|
|
86
|
+
if self._owns_http:
|
|
87
|
+
self._http.close()
|
|
88
|
+
|
|
89
|
+
def __enter__(self) -> "Pushizo":
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
93
|
+
self.close()
|
|
94
|
+
|
|
95
|
+
# -- yardımcılar ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def identity_hash(sdk_secret: str, external_id: str) -> str:
|
|
99
|
+
"""SDK identity verification için ``identity_hash = HMAC-SHA256(sdk_secret, external_id)`` (hex)."""
|
|
100
|
+
return hmac.new(sdk_secret.encode("utf-8"), external_id.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def identity_token(private_key_pem: str, kid: str, app_id: str, external_id: str, expires_in: int = 3600) -> str:
|
|
104
|
+
"""SDK JWT identity verification belirteci (ES256); bkz. :func:`pushizo.identity_token`."""
|
|
105
|
+
return identity_token(private_key_pem, kid, app_id, external_id, expires_in)
|
|
106
|
+
|
|
107
|
+
def request(
|
|
108
|
+
self,
|
|
109
|
+
method: str,
|
|
110
|
+
path: str,
|
|
111
|
+
*,
|
|
112
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
113
|
+
body: Any = None,
|
|
114
|
+
request_options: Optional[RequestOptions] = None,
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""Düşük seviyeli istek (SDK'da henüz metodu olmayan uçlar veya ``format=csv`` gibi JSON dışı yanıtlar için).
|
|
117
|
+
|
|
118
|
+
JSON yanıtlar ayrıştırılır, diğer içerik tipleri metin olarak döner.
|
|
119
|
+
"""
|
|
120
|
+
return self._send(method, path, list(_query_items(query or {})), body, None, None, None, request_options)
|
|
121
|
+
|
|
122
|
+
# -- üretilmiş kaynakların kullandığı iç API -----------------------------------------------
|
|
123
|
+
|
|
124
|
+
def _resolve_app_id(self, app_id: Optional[str]) -> str:
|
|
125
|
+
resolved = app_id or self.app_id
|
|
126
|
+
if not resolved:
|
|
127
|
+
raise ValueError("Pushizo: app_id verilmedi (istemci seçeneği veya çağrı parametresi).")
|
|
128
|
+
return resolved
|
|
129
|
+
|
|
130
|
+
def _call(
|
|
131
|
+
self,
|
|
132
|
+
method: str,
|
|
133
|
+
path: str,
|
|
134
|
+
*,
|
|
135
|
+
path_params: Mapping[str, str],
|
|
136
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
137
|
+
body: Any = None,
|
|
138
|
+
form: Optional[Mapping[str, Any]] = None,
|
|
139
|
+
files: Optional[Mapping[str, Optional[FileInput]]] = None,
|
|
140
|
+
app_id: Optional[str] = None,
|
|
141
|
+
query_app_id: bool = False,
|
|
142
|
+
body_app_id: bool = False,
|
|
143
|
+
idempotent: bool = False,
|
|
144
|
+
idempotency_key: Optional[str] = None,
|
|
145
|
+
request_options: Optional[RequestOptions] = None,
|
|
146
|
+
) -> Any:
|
|
147
|
+
url_path = path
|
|
148
|
+
if "{appId}" in url_path:
|
|
149
|
+
url_path = url_path.replace("{appId}", quote(self._resolve_app_id(app_id), safe=""))
|
|
150
|
+
for name, value in path_params.items():
|
|
151
|
+
if value is None or value == "":
|
|
152
|
+
raise ValueError(f"Pushizo: '{name}' yol parametresi boş olamaz.")
|
|
153
|
+
url_path = url_path.replace("{" + name + "}", quote(str(value), safe=""))
|
|
154
|
+
|
|
155
|
+
items: List[Tuple[str, str]] = []
|
|
156
|
+
if query_app_id:
|
|
157
|
+
items.append(("app_id", self._resolve_app_id(app_id)))
|
|
158
|
+
items.extend(_query_items(query or {}))
|
|
159
|
+
|
|
160
|
+
if body_app_id and isinstance(body, Mapping) and not body.get("app_id"):
|
|
161
|
+
body = {**body, "app_id": self._resolve_app_id(app_id)}
|
|
162
|
+
|
|
163
|
+
key = idempotency_key or (request_options or {}).get("idempotency_key")
|
|
164
|
+
if not key and isinstance(body, Mapping) and isinstance(body.get("idempotency_key"), str):
|
|
165
|
+
key = body["idempotency_key"]
|
|
166
|
+
if not key and idempotent and self.auto_idempotency_key:
|
|
167
|
+
key = str(uuid.uuid4())
|
|
168
|
+
|
|
169
|
+
return self._send(method, url_path, items, body, form, files, key, request_options)
|
|
170
|
+
|
|
171
|
+
def _paginate(self, fetch: Callable[[Optional[str]], Any], cursor: Optional[str]) -> Iterator[Any]:
|
|
172
|
+
"""``data`` / ``next_cursor`` / ``has_more`` zarfını sayfa sayfa dolaşır."""
|
|
173
|
+
|
|
174
|
+
def generator() -> Iterator[Any]:
|
|
175
|
+
next_cursor = cursor
|
|
176
|
+
while True:
|
|
177
|
+
page = fetch(next_cursor) or {}
|
|
178
|
+
for item in page.get("data") or []:
|
|
179
|
+
yield item
|
|
180
|
+
next_cursor = page.get("next_cursor")
|
|
181
|
+
if not page.get("has_more") or not next_cursor:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
return generator()
|
|
185
|
+
|
|
186
|
+
# -- HTTP ----------------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def _send(
|
|
189
|
+
self,
|
|
190
|
+
method: str,
|
|
191
|
+
path: str,
|
|
192
|
+
query: List[Tuple[str, str]],
|
|
193
|
+
body: Any,
|
|
194
|
+
form: Optional[Mapping[str, Any]],
|
|
195
|
+
files: Optional[Mapping[str, Optional[FileInput]]],
|
|
196
|
+
idempotency_key: Optional[str],
|
|
197
|
+
request_options: Optional[RequestOptions],
|
|
198
|
+
) -> Any:
|
|
199
|
+
opts: RequestOptions = request_options or {}
|
|
200
|
+
headers: Dict[str, str] = {
|
|
201
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
202
|
+
"Accept": "application/json",
|
|
203
|
+
"User-Agent": self.user_agent,
|
|
204
|
+
}
|
|
205
|
+
content: Optional[bytes] = None
|
|
206
|
+
data: Optional[Dict[str, str]] = None
|
|
207
|
+
upload: Optional[Dict[str, Tuple[str, bytes, str]]] = None
|
|
208
|
+
if files is not None:
|
|
209
|
+
data = {k: _form_value(v) for k, v in (form or {}).items() if v is not None}
|
|
210
|
+
upload = {k: _normalize_file(v) for k, v in files.items() if v is not None}
|
|
211
|
+
elif body is not None:
|
|
212
|
+
headers["Content-Type"] = "application/json"
|
|
213
|
+
content = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
214
|
+
if idempotency_key:
|
|
215
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
216
|
+
headers.update(opts.get("headers") or {})
|
|
217
|
+
|
|
218
|
+
url = f"{self.base_url}{path}"
|
|
219
|
+
timeout = opts.get("timeout", self.timeout)
|
|
220
|
+
max_retries = opts.get("max_retries", self.max_retries)
|
|
221
|
+
# Gövdedeki idempotency_key, API tarafında Idempotency-Key başlığıyla eşdeğerdir.
|
|
222
|
+
has_key = any(k.lower() == "idempotency-key" and v for k, v in headers.items()) or (
|
|
223
|
+
isinstance(body, Mapping) and isinstance(body.get("idempotency_key"), str) and bool(body["idempotency_key"])
|
|
224
|
+
)
|
|
225
|
+
attempt = 0
|
|
226
|
+
while True:
|
|
227
|
+
response: Optional[httpx.Response] = None
|
|
228
|
+
not_sent = False
|
|
229
|
+
try:
|
|
230
|
+
response = self._http.request(
|
|
231
|
+
method,
|
|
232
|
+
url,
|
|
233
|
+
params=query or None,
|
|
234
|
+
headers=headers,
|
|
235
|
+
content=content,
|
|
236
|
+
data=data,
|
|
237
|
+
files=upload,
|
|
238
|
+
timeout=timeout,
|
|
239
|
+
)
|
|
240
|
+
except httpx.TransportError as exc:
|
|
241
|
+
error = NetworkError(0, "NETWORK", str(exc) or exc.__class__.__name__)
|
|
242
|
+
not_sent = isinstance(exc, _NOT_SENT_ERRORS)
|
|
243
|
+
else:
|
|
244
|
+
if 200 <= response.status_code < 300:
|
|
245
|
+
return _parse_body(response)
|
|
246
|
+
error = _error_from_response(response)
|
|
247
|
+
if attempt >= max_retries or not _should_retry(method, error, has_key, not_sent):
|
|
248
|
+
raise error
|
|
249
|
+
self._sleep(_retry_delay(response, attempt))
|
|
250
|
+
attempt += 1
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
#: Sunucuda tekrarlanması ek yan etki doğurmayan HTTP metotları; 5xx ve ağ hatalarında serbestçe yeniden denenir.
|
|
254
|
+
_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
|
255
|
+
|
|
256
|
+
#: İstek sunucuya hiç ulaşmadan oluşan aktarım hataları: bağlantı reddi / DNS çözümlenemedi (``ConnectError``),
|
|
257
|
+
#: bağlantı kurma ve bağlantı havuzu zaman aşımı.
|
|
258
|
+
_NOT_SENT_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _should_retry(method: str, error: PushizoError, has_idempotency_key: bool, not_sent: bool) -> bool:
|
|
262
|
+
"""Yeniden deneme politikası.
|
|
263
|
+
|
|
264
|
+
- 429: her metotta yeniden denenir (istek işlenmeden reddedilmiştir; ``Retry-After``'a uyulur).
|
|
265
|
+
- 5xx / ağ hatası: GET, HEAD, OPTIONS, PUT, DELETE her zaman; POST ve PATCH yalnızca ``Idempotency-Key``
|
|
266
|
+
taşıyorsa (otomatik anahtar dahil) veya istek sunucuya hiç gönderilmeden başarısız olduysa (bağlantı reddi,
|
|
267
|
+
DNS hatası, bağlantı zaman aşımı). Aksi hâlde sunucu isteği işlemiş olabilir; tekrar, çift kayıt üretebilir.
|
|
268
|
+
"""
|
|
269
|
+
if error.status == 429:
|
|
270
|
+
return True
|
|
271
|
+
if not error.retryable:
|
|
272
|
+
return False
|
|
273
|
+
return method.upper() in _IDEMPOTENT_METHODS or has_idempotency_key or not_sent
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _query_items(query: Mapping[str, Any]) -> Iterator[Tuple[str, str]]:
|
|
277
|
+
for key, value in query.items():
|
|
278
|
+
if value is None:
|
|
279
|
+
continue
|
|
280
|
+
if isinstance(value, (list, tuple)):
|
|
281
|
+
for v in value:
|
|
282
|
+
if v is not None:
|
|
283
|
+
yield key, _scalar(v)
|
|
284
|
+
else:
|
|
285
|
+
yield key, _scalar(value)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _scalar(value: Any) -> str:
|
|
289
|
+
if isinstance(value, bool):
|
|
290
|
+
return "true" if value else "false"
|
|
291
|
+
if isinstance(value, (str, int, float)):
|
|
292
|
+
return str(value)
|
|
293
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _form_value(value: Any) -> str:
|
|
297
|
+
return _scalar(value)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _normalize_file(value: FileInput) -> Tuple[str, bytes, str]:
|
|
301
|
+
"""Dosya girdisini (ad, bayt, içerik tipi) üçlüsüne çevirir; akış bir kez okunur ki yeniden denemeler aynı içeriği göndersin."""
|
|
302
|
+
filename: Optional[str] = None
|
|
303
|
+
content_type: Optional[str] = None
|
|
304
|
+
raw: Any = value
|
|
305
|
+
if isinstance(value, tuple):
|
|
306
|
+
filename = value[0]
|
|
307
|
+
raw = value[1]
|
|
308
|
+
if len(value) > 2:
|
|
309
|
+
content_type = value[2] # type: ignore[misc]
|
|
310
|
+
if not isinstance(raw, (bytes, bytearray)):
|
|
311
|
+
if filename is None:
|
|
312
|
+
name = getattr(raw, "name", None)
|
|
313
|
+
if isinstance(name, str):
|
|
314
|
+
filename = os.path.basename(name)
|
|
315
|
+
raw = raw.read()
|
|
316
|
+
if filename is None:
|
|
317
|
+
filename = "upload"
|
|
318
|
+
if content_type is None:
|
|
319
|
+
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
320
|
+
return filename, bytes(raw), content_type
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
324
|
+
if not response.content:
|
|
325
|
+
return None
|
|
326
|
+
content_type = response.headers.get("content-type", "")
|
|
327
|
+
if "json" in content_type or not content_type:
|
|
328
|
+
return response.json()
|
|
329
|
+
return response.text
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _error_from_response(response: httpx.Response) -> PushizoError:
|
|
333
|
+
envelope: Dict[str, Any] = {}
|
|
334
|
+
try:
|
|
335
|
+
parsed = response.json()
|
|
336
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
337
|
+
envelope = parsed["error"]
|
|
338
|
+
except ValueError:
|
|
339
|
+
envelope = {}
|
|
340
|
+
details = envelope.get("details")
|
|
341
|
+
return error_class_for_status(response.status_code)(
|
|
342
|
+
response.status_code,
|
|
343
|
+
str(envelope.get("code") or f"HTTP_{response.status_code}"),
|
|
344
|
+
str(envelope.get("message") or f"HTTP {response.status_code}"),
|
|
345
|
+
envelope.get("request_id") or response.headers.get("x-request-id"),
|
|
346
|
+
details if isinstance(details, dict) else None,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _retry_delay(response: Optional[httpx.Response], attempt: int) -> float:
|
|
351
|
+
"""``Retry-After`` (saniye veya HTTP tarihi) varsa ona uyar; yoksa 0.3 / 1 / 3 sn geri çekilme."""
|
|
352
|
+
if response is not None:
|
|
353
|
+
header = response.headers.get("retry-after")
|
|
354
|
+
if header:
|
|
355
|
+
header = header.strip()
|
|
356
|
+
try:
|
|
357
|
+
seconds = float(header)
|
|
358
|
+
if seconds > 0:
|
|
359
|
+
return seconds
|
|
360
|
+
except ValueError:
|
|
361
|
+
try:
|
|
362
|
+
delta = parsedate_to_datetime(header).timestamp() - time.time()
|
|
363
|
+
if delta > 0:
|
|
364
|
+
return delta
|
|
365
|
+
except (TypeError, ValueError):
|
|
366
|
+
pass
|
|
367
|
+
return _BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)]
|
pushizo/_errors.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Pushizo istisnaları ve hata kodları."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Type
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ErrorCode:
|
|
7
|
+
"""API hata zarfındaki ``error.code`` değerleri (``HS_`` önekli katalog)."""
|
|
8
|
+
|
|
9
|
+
VALIDATION_ERROR = "HS_VALIDATION_ERROR"
|
|
10
|
+
BAD_REQUEST = "HS_BAD_REQUEST"
|
|
11
|
+
UNAUTHENTICATED = "HS_UNAUTHENTICATED"
|
|
12
|
+
FORBIDDEN = "HS_FORBIDDEN"
|
|
13
|
+
TENANT_MISMATCH = "HS_TENANT_MISMATCH"
|
|
14
|
+
NOT_FOUND = "HS_NOT_FOUND"
|
|
15
|
+
CONFLICT = "HS_CONFLICT"
|
|
16
|
+
IDEMPOTENCY_CONFLICT = "HS_IDEMPOTENCY_CONFLICT"
|
|
17
|
+
IDEMPOTENCY_IN_PROGRESS = "HS_IDEMPOTENCY_IN_PROGRESS"
|
|
18
|
+
NO_RECIPIENTS = "HS_NO_RECIPIENTS"
|
|
19
|
+
CHANNEL_NOT_CONFIGURED = "HS_CHANNEL_NOT_CONFIGURED"
|
|
20
|
+
CONTENT_INVALID = "HS_CONTENT_INVALID"
|
|
21
|
+
SUBSCRIPTION_INVALID = "HS_SUBSCRIPTION_INVALID"
|
|
22
|
+
UNPROCESSABLE = "HS_UNPROCESSABLE"
|
|
23
|
+
RATE_LIMITED = "HS_RATE_LIMITED"
|
|
24
|
+
QUOTA_EXCEEDED = "HS_QUOTA_EXCEEDED"
|
|
25
|
+
INTERNAL = "HS_INTERNAL"
|
|
26
|
+
NOT_IMPLEMENTED = "HS_NOT_IMPLEMENTED"
|
|
27
|
+
PROVIDER_UNAVAILABLE = "HS_PROVIDER_UNAVAILABLE"
|
|
28
|
+
SERVICE_UNAVAILABLE = "HS_SERVICE_UNAVAILABLE"
|
|
29
|
+
#: İstemci tarafı: bağlantı kurulamadı / zaman aşımı (``status`` 0).
|
|
30
|
+
NETWORK = "NETWORK"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PushizoError(Exception):
|
|
34
|
+
"""API hata zarfından (``{"error": {"code", "message", "request_id", "details"}}``) türetilen istisna.
|
|
35
|
+
|
|
36
|
+
Ağ hatalarında ``status`` 0 ve ``code`` ``"NETWORK"`` olur. HTTP durumuna göre alt sınıflar
|
|
37
|
+
(:class:`NotFoundError`, :class:`RateLimitError` …) fırlatılır; hepsi bu sınıftan türer.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
status: int,
|
|
43
|
+
code: str,
|
|
44
|
+
message: str,
|
|
45
|
+
request_id: Optional[str] = None,
|
|
46
|
+
details: Optional[Dict[str, Any]] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.status = status
|
|
50
|
+
self.code = code
|
|
51
|
+
self.message = message
|
|
52
|
+
self.request_id = request_id
|
|
53
|
+
self.details = details
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def retryable(self) -> bool:
|
|
57
|
+
"""429, 5xx veya ağ hatası: yeniden denenebilir."""
|
|
58
|
+
return self.status == 0 or self.status == 429 or self.status >= 500
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
suffix = f" (request_id={self.request_id})" if self.request_id else ""
|
|
62
|
+
return f"[{self.status} {self.code}] {self.message}{suffix}"
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str:
|
|
65
|
+
return (
|
|
66
|
+
f"{type(self).__name__}(status={self.status!r}, code={self.code!r}, "
|
|
67
|
+
f"message={self.message!r}, request_id={self.request_id!r})"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NetworkError(PushizoError):
|
|
72
|
+
"""Bağlantı kurulamadı veya zaman aşımı (``status`` 0)."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InvalidRequestError(PushizoError):
|
|
76
|
+
"""400 / 422: istek doğrulanamadı veya işlenemedi (``details`` alan hatalarını içerir)."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AuthenticationError(PushizoError):
|
|
80
|
+
"""401: API anahtarı eksik veya geçersiz."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PermissionDeniedError(PushizoError):
|
|
84
|
+
"""403: anahtarın kapsamı yetersiz, IP izin listesi dışı veya kaynak başka kiracıya ait."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NotFoundError(PushizoError):
|
|
88
|
+
"""404: kaynak bulunamadı."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ConflictError(PushizoError):
|
|
92
|
+
"""409: kaynak çakışması veya idempotency anahtarı çakışması."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RateLimitError(PushizoError):
|
|
96
|
+
"""429: hız sınırı veya plan kotası aşıldı."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ServerError(PushizoError):
|
|
100
|
+
"""5xx: sunucu veya sağlayıcı geçici olarak kullanılamıyor."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def error_class_for_status(status: int) -> Type[PushizoError]:
|
|
104
|
+
"""HTTP durumuna karşılık gelen istisna sınıfı."""
|
|
105
|
+
if status == 0:
|
|
106
|
+
return NetworkError
|
|
107
|
+
if status in (400, 422):
|
|
108
|
+
return InvalidRequestError
|
|
109
|
+
if status == 401:
|
|
110
|
+
return AuthenticationError
|
|
111
|
+
if status == 403:
|
|
112
|
+
return PermissionDeniedError
|
|
113
|
+
if status == 404:
|
|
114
|
+
return NotFoundError
|
|
115
|
+
if status == 409:
|
|
116
|
+
return ConflictError
|
|
117
|
+
if status == 429:
|
|
118
|
+
return RateLimitError
|
|
119
|
+
if status >= 500:
|
|
120
|
+
return ServerError
|
|
121
|
+
return PushizoError
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class WebhookSignatureError(Exception):
|
|
125
|
+
"""Webhook imzası geçersiz. ``reason``: ``missing_header``, ``malformed_header``,
|
|
126
|
+
``timestamp_out_of_tolerance``, ``signature_mismatch`` veya ``malformed_body``."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, reason: str) -> None:
|
|
129
|
+
super().__init__(f"Pushizo webhook imzası geçersiz: {reason}")
|
|
130
|
+
self.reason = reason
|