ponk 0.2.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.
ponk/__init__.py ADDED
@@ -0,0 +1,105 @@
1
+ """ponk - a thin Python client for the ponk public API.
2
+
3
+ from ponk import PonkClient
4
+
5
+ ponk = PonkClient(api_key="ponk_live_...")
6
+ print(ponk.whoami().wallet_address)
7
+
8
+ See `README.md` for the quickstart and `ponk.client.PonkClient` for the method
9
+ list. Standard library only.
10
+ """
11
+
12
+ from .client import (
13
+ DEFAULT_BASE_URL,
14
+ DEFAULT_FUND_TIMEOUT,
15
+ DEFAULT_TIMEOUT,
16
+ PonkClient,
17
+ )
18
+ from .webhooks import (
19
+ EVENT_KINDS,
20
+ InvalidSignature,
21
+ WebhookEvent,
22
+ parse_signature_header,
23
+ verify,
24
+ )
25
+ from .errors import (
26
+ PonkAPIError,
27
+ PonkAuthError,
28
+ PonkBadRequestError,
29
+ PonkConflictError,
30
+ PonkError,
31
+ PonkForbiddenError,
32
+ PonkNotFoundError,
33
+ PonkRateLimitedError,
34
+ PonkServerError,
35
+ PonkTransportError,
36
+ PonkUnprocessableError,
37
+ )
38
+ from .models import (
39
+ ActionLog,
40
+ ActionReceipt,
41
+ Agent,
42
+ AgentPerformance,
43
+ AgentPosition,
44
+ AgentWallet,
45
+ BinShare,
46
+ ClaimPayout,
47
+ ClaimPayoutToken,
48
+ ComponentStatus,
49
+ ExitSnapshot,
50
+ FeeRates,
51
+ Health,
52
+ HealthChecks,
53
+ PonkPerks,
54
+ PoolSnapshot,
55
+ Position,
56
+ TokenAmount,
57
+ WhoAmI,
58
+ Withdrawal,
59
+ )
60
+
61
+ __version__ = "0.2.0"
62
+
63
+ __all__ = [
64
+ "EVENT_KINDS",
65
+ "InvalidSignature",
66
+ "WebhookEvent",
67
+ "parse_signature_header",
68
+ "verify",
69
+ "__version__",
70
+ "DEFAULT_BASE_URL",
71
+ "DEFAULT_FUND_TIMEOUT",
72
+ "DEFAULT_TIMEOUT",
73
+ "PonkClient",
74
+ "PonkAPIError",
75
+ "PonkAuthError",
76
+ "PonkBadRequestError",
77
+ "PonkConflictError",
78
+ "PonkError",
79
+ "PonkForbiddenError",
80
+ "PonkNotFoundError",
81
+ "PonkRateLimitedError",
82
+ "PonkServerError",
83
+ "PonkTransportError",
84
+ "PonkUnprocessableError",
85
+ "ActionLog",
86
+ "ActionReceipt",
87
+ "Agent",
88
+ "AgentPerformance",
89
+ "AgentPosition",
90
+ "AgentWallet",
91
+ "BinShare",
92
+ "ClaimPayout",
93
+ "ClaimPayoutToken",
94
+ "ComponentStatus",
95
+ "ExitSnapshot",
96
+ "FeeRates",
97
+ "Health",
98
+ "HealthChecks",
99
+ "PonkPerks",
100
+ "PoolSnapshot",
101
+ "Position",
102
+ "TokenAmount",
103
+ "WhoAmI",
104
+ "Withdrawal",
105
+ ]
ponk/client.py ADDED
@@ -0,0 +1,374 @@
1
+ """A thin client for the ponk public API.
2
+
3
+ One method per endpoint, no dependencies beyond the standard library, and no
4
+ behaviour of its own: it builds the request, sends it, maps the error envelope
5
+ to an exception and parses the body into the types in `ponk.models`. It never
6
+ computes, rounds or fills in a number the server did not send.
7
+
8
+ from ponk import PonkClient
9
+
10
+ ponk = PonkClient(api_key="ponk_live_...")
11
+ me = ponk.whoami()
12
+ for agent in ponk.list_agents():
13
+ print(agent.name, agent.status)
14
+
15
+ What a key cannot do, by construction rather than by configuration:
16
+
17
+ * It cannot sign with your connected wallet. Non-custodial actions still need
18
+ your signature in the app; this client can read their state, not produce it.
19
+ * It cannot grant itself custody. Turning an agent autonomous needs a one-time
20
+ custody mandate signed with your wallet in the app. Create the agent here,
21
+ enable autonomous mode once there, and from then on this client can drive it.
22
+ * `compound`, `withdraw`, `exit` and `agent_wallet` act on the isolated wallet
23
+ an autonomous agent owns. A self-custody agent has no such wallet, so those
24
+ four do not apply to it.
25
+
26
+ Withdrawals and exits have no destination parameter and cannot be given one.
27
+ The server locks the destination to the wallet that owns the agent.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import socket
34
+ import urllib.error
35
+ import urllib.parse
36
+ import urllib.request
37
+ from typing import Any, Dict, List, Mapping, Optional
38
+
39
+ from .errors import PonkTransportError, error_from_response
40
+ from .models import (
41
+ ActionLog,
42
+ ActionReceipt,
43
+ Agent,
44
+ AgentPerformance,
45
+ AgentPosition,
46
+ AgentWallet,
47
+ Health,
48
+ PonkPerks,
49
+ PoolSnapshot,
50
+ Position,
51
+ WhoAmI,
52
+ Withdrawal,
53
+ )
54
+
55
+ __all__ = ["PonkClient", "DEFAULT_BASE_URL", "DEFAULT_TIMEOUT", "DEFAULT_FUND_TIMEOUT"]
56
+
57
+ DEFAULT_BASE_URL = "https://ponk.exchange/api"
58
+
59
+ #: Read and control calls. Anything slower than this is a bug on the server.
60
+ DEFAULT_TIMEOUT = 30.0
61
+
62
+ #: compound, withdraw and exit send several transactions and wait for each
63
+ #: confirmation, so they get their own, longer timeout.
64
+ DEFAULT_FUND_TIMEOUT = 180.0
65
+
66
+ _USER_AGENT = "ponk-python/0.1.0"
67
+
68
+
69
+ class PonkClient:
70
+ """Construct with the base URL and your API key.
71
+
72
+ Args:
73
+ api_key: a `ponk_live_...` secret from Settings, API keys. Leave it out
74
+ to reach only the endpoints that need no key (`health`, `pool`,
75
+ `ponk_perks`); every other method will then get a 401 from the
76
+ server.
77
+ base_url: defaults to production. Point it elsewhere to test.
78
+ timeout: seconds for read and control calls.
79
+ fund_timeout: seconds for compound, withdraw and exit.
80
+ opener: a `urllib.request.OpenerDirector` to send through, for a proxy
81
+ or for tests.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ api_key: Optional[str] = None,
87
+ base_url: str = DEFAULT_BASE_URL,
88
+ *,
89
+ timeout: float = DEFAULT_TIMEOUT,
90
+ fund_timeout: float = DEFAULT_FUND_TIMEOUT,
91
+ opener: Optional[urllib.request.OpenerDirector] = None,
92
+ ) -> None:
93
+ self.base_url = base_url.rstrip("/")
94
+ self.api_key = api_key
95
+ self.timeout = timeout
96
+ self.fund_timeout = fund_timeout
97
+ self._opener = opener or urllib.request.build_opener()
98
+
99
+ # -- transport ---------------------------------------------------------
100
+
101
+ def _request(
102
+ self,
103
+ method: str,
104
+ path: str,
105
+ *,
106
+ body: Optional[Mapping[str, Any]] = None,
107
+ params: Optional[Mapping[str, Any]] = None,
108
+ timeout: Optional[float] = None,
109
+ allow_statuses: tuple = (),
110
+ ) -> Any:
111
+ url = self.base_url + path
112
+ if params:
113
+ query = {k: v for k, v in params.items() if v is not None}
114
+ if query:
115
+ url = url + "?" + urllib.parse.urlencode(query)
116
+
117
+ data = None
118
+ headers = {"Accept": "application/json", "User-Agent": _USER_AGENT}
119
+ if body is not None:
120
+ data = json.dumps(body).encode("utf-8")
121
+ headers["Content-Type"] = "application/json"
122
+ if self.api_key:
123
+ headers["Authorization"] = "Bearer " + self.api_key
124
+
125
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
126
+ try:
127
+ with self._opener.open(request, timeout=timeout or self.timeout) as response:
128
+ return _decode(response.read())
129
+ except urllib.error.HTTPError as exc: # a real response, non-2xx
130
+ payload = _decode(exc.read())
131
+ # A status the caller declared meaningful, carrying a real body
132
+ # rather than the error envelope, is a result and not a failure.
133
+ # `/health` answers 503 with the full report of what is down.
134
+ if (
135
+ exc.code in allow_statuses
136
+ and isinstance(payload, Mapping)
137
+ and "error" not in payload
138
+ ):
139
+ return payload
140
+ raise error_from_response(exc.code, payload) from None
141
+ except urllib.error.URLError as exc:
142
+ raise PonkTransportError("{0} {1} failed: {2}".format(method, url, exc.reason)) from exc
143
+ except socket.timeout as exc:
144
+ raise PonkTransportError(
145
+ "{0} {1} timed out after {2}s".format(method, url, timeout or self.timeout)
146
+ ) from exc
147
+
148
+ # -- public, no key required -------------------------------------------
149
+
150
+ def health(self) -> Health:
151
+ """`GET /health`. Whether the API and its database and RPC are up.
152
+
153
+ A degraded API answers 503 with the full report of which component is
154
+ down, and that report is the point of the call, so this returns it
155
+ rather than raising. Check `status` (`ok` or `degraded`) and
156
+ `checks.database.healthy` / `checks.helius.healthy`.
157
+ """
158
+ return Health.from_dict(self._request("GET", "/health", allow_statuses=(503,)))
159
+
160
+ def pool(self, dex: str, address: str) -> PoolSnapshot:
161
+ """`GET /pools/{dex}/{address}`. One pool, decoded from chain.
162
+
163
+ `dex` is `meteora_dlmm`, `orca` or `ponk_clouds`. This route is rate
164
+ limited per IP: it spends the same RPC budget the live agents use.
165
+ """
166
+ return PoolSnapshot.from_dict(
167
+ self._request("GET", "/pools/{0}/{1}".format(_seg(dex), _seg(address)))
168
+ )
169
+
170
+ def ponk_perks(self, wallet_address: str) -> PonkPerks:
171
+ """`GET /public/ponk/perks/{address}`. What fees a wallet pays.
172
+
173
+ Reads only the wallet's public $PONK balance. Works for any wallet, no
174
+ key and no signature.
175
+ """
176
+ return PonkPerks.from_dict(
177
+ self._request("GET", "/public/ponk/perks/{0}".format(_seg(wallet_address)))
178
+ )
179
+
180
+ # -- identity ----------------------------------------------------------
181
+
182
+ def whoami(self) -> WhoAmI:
183
+ """`GET /v1/me`. The first call any integration should make."""
184
+ return WhoAmI.from_dict(self._request("GET", "/v1/me"))
185
+
186
+ # -- agents, read ------------------------------------------------------
187
+
188
+ def list_agents(self) -> List[Agent]:
189
+ """`GET /v1/agents`. Every agent this key's wallet owns."""
190
+ return Agent.from_list(self._request("GET", "/v1/agents"))
191
+
192
+ def get_agent(self, agent_id: str) -> Agent:
193
+ """`GET /v1/agents/{id}`. A foreign or missing id is 404 alike."""
194
+ return Agent.from_dict(self._request("GET", "/v1/agents/{0}".format(_seg(agent_id))))
195
+
196
+ def agent_performance(self, agent_id: str) -> AgentPerformance:
197
+ """`GET /v1/agents/{id}/performance`. Live value, PnL, fees, IL."""
198
+ return AgentPerformance.from_dict(
199
+ self._request("GET", "/v1/agents/{0}/performance".format(_seg(agent_id)))
200
+ )
201
+
202
+ def agent_position(self, agent_id: str) -> AgentPosition:
203
+ """`GET /v1/agents/{id}/position`. The live on-chain range."""
204
+ return AgentPosition.from_dict(
205
+ self._request("GET", "/v1/agents/{0}/position".format(_seg(agent_id)))
206
+ )
207
+
208
+ def agent_wallet(self, agent_id: str) -> AgentWallet:
209
+ """`GET /v1/agents/{id}/wallet`. The agent's own managed wallet.
210
+
211
+ Autonomous agents only.
212
+ """
213
+ return AgentWallet.from_dict(
214
+ self._request("GET", "/v1/agents/{0}/wallet".format(_seg(agent_id)))
215
+ )
216
+
217
+ def list_positions(self) -> List[Position]:
218
+ """`GET /v1/positions`. Every LP position the wallet holds.
219
+
220
+ Agent-managed or not, re-read from chain on each call.
221
+ """
222
+ return Position.from_list(self._request("GET", "/v1/positions"))
223
+
224
+ def list_logs(self, limit: Optional[int] = None) -> List[ActionLog]:
225
+ """`GET /v1/logs`. Recent agent activity, newest first."""
226
+ return ActionLog.from_list(
227
+ self._request("GET", "/v1/logs", params={"limit": limit})
228
+ )
229
+
230
+ # -- agents, write (needs a `trade` key) -------------------------------
231
+
232
+ def create_agent(
233
+ self,
234
+ *,
235
+ name: str,
236
+ wallet_address: str,
237
+ dex: str,
238
+ strategy: str,
239
+ config: Mapping[str, Any],
240
+ pool_address: Optional[str] = None,
241
+ position_address: Optional[str] = None,
242
+ dry_run: Optional[bool] = None,
243
+ ) -> Agent:
244
+ """`POST /v1/agents`. Create an agent.
245
+
246
+ `wallet_address` must be this key's own wallet; anything else is
247
+ rejected as a cross-account attempt. `config` is validated against
248
+ `strategy`, so a mismatched shape comes back as a field-precise 400
249
+ rather than a broken agent.
250
+
251
+ An agent created here is stamped `origin='api'` and pays the API
252
+ performance rate for its whole life, including after you enable
253
+ autonomous mode for it in the app. `ponk_perks(wallet).api_agent_fee`
254
+ is that rate.
255
+
256
+ Leave `dry_run` unset to take the server's default, which is `True`: a
257
+ dry-run agent runs its whole strategy loop and logs every decision to
258
+ `list_logs` without sending a transaction.
259
+ """
260
+ body: Dict[str, Any] = {
261
+ "name": name,
262
+ "wallet_address": wallet_address,
263
+ "dex": dex,
264
+ "strategy": strategy,
265
+ "config": config,
266
+ }
267
+ if pool_address is not None:
268
+ body["pool_address"] = pool_address
269
+ if position_address is not None:
270
+ body["position_address"] = position_address
271
+ if dry_run is not None:
272
+ body["dry_run"] = dry_run
273
+ return Agent.from_dict(self._request("POST", "/v1/agents", body=body))
274
+
275
+ def pause_agent(self, agent_id: str) -> Agent:
276
+ """`POST /v1/agents/{id}/pause`. Stop the loop.
277
+
278
+ The position stays open and keeps earning. Nothing is closed or swept.
279
+ """
280
+ return Agent.from_dict(
281
+ self._request("POST", "/v1/agents/{0}/pause".format(_seg(agent_id)))
282
+ )
283
+
284
+ def resume_agent(self, agent_id: str) -> Agent:
285
+ """`POST /v1/agents/{id}/resume`. Restart the loop."""
286
+ return Agent.from_dict(
287
+ self._request("POST", "/v1/agents/{0}/resume".format(_seg(agent_id)))
288
+ )
289
+
290
+ def set_dry_run(self, agent_id: str, dry_run: bool) -> Agent:
291
+ """`POST /v1/agents/{id}/dry-run`. Simulate instead of sending."""
292
+ return Agent.from_dict(
293
+ self._request(
294
+ "POST",
295
+ "/v1/agents/{0}/dry-run".format(_seg(agent_id)),
296
+ body={"dry_run": dry_run},
297
+ )
298
+ )
299
+
300
+ def set_mode(self, agent_id: str, mode: str) -> Agent:
301
+ """`POST /v1/agents/{id}/mode`. `manual` or `auto`."""
302
+ return Agent.from_dict(
303
+ self._request(
304
+ "POST",
305
+ "/v1/agents/{0}/mode".format(_seg(agent_id)),
306
+ body={"mode": mode},
307
+ )
308
+ )
309
+
310
+ # -- agents, fund moving (needs a `trade` key) -------------------------
311
+
312
+ def compound(self, agent_id: str) -> ActionReceipt:
313
+ """`POST /v1/agents/{id}/compound`. Claim fees and redeposit them now.
314
+
315
+ Meteora DLMM autonomous agents only. The range is read from chain, so
316
+ the re-deposit cannot be redirected. Takes the fund timeout.
317
+ """
318
+ return ActionReceipt.from_dict(
319
+ self._request(
320
+ "POST",
321
+ "/v1/agents/{0}/compound".format(_seg(agent_id)),
322
+ timeout=self.fund_timeout,
323
+ )
324
+ )
325
+
326
+ def withdraw(self, agent_id: str, lamports: Optional[int] = None) -> Withdrawal:
327
+ """`POST /v1/agents/{id}/withdraw`. Move SOL out of the agent's wallet.
328
+
329
+ Omit `lamports` to sweep everything spendable. The destination is the
330
+ wallet that owns the agent and cannot be named by the request.
331
+ """
332
+ body: Dict[str, Any] = {}
333
+ if lamports is not None:
334
+ body["lamports"] = lamports
335
+ return Withdrawal.from_dict(
336
+ self._request(
337
+ "POST",
338
+ "/v1/agents/{0}/withdraw".format(_seg(agent_id)),
339
+ body=body,
340
+ timeout=self.fund_timeout,
341
+ )
342
+ )
343
+
344
+ def exit_agent(self, agent_id: str) -> Withdrawal:
345
+ """`POST /v1/agents/{id}/exit`. The full stop.
346
+
347
+ Halts the agent, closes its position and sweeps every token plus native
348
+ SOL back to the owner's wallet. Idempotent: if a call times out the
349
+ work keeps running on the server, and calling again continues it rather
350
+ than double-sending.
351
+ """
352
+ return Withdrawal.from_dict(
353
+ self._request(
354
+ "POST",
355
+ "/v1/agents/{0}/exit".format(_seg(agent_id)),
356
+ timeout=self.fund_timeout,
357
+ )
358
+ )
359
+
360
+
361
+ def _seg(value: str) -> str:
362
+ """Percent-encode one path segment, so an id can never change the route."""
363
+ return urllib.parse.quote(str(value), safe="")
364
+
365
+
366
+ def _decode(raw: bytes) -> Any:
367
+ """Decode a body as JSON, falling back to the text the server actually sent."""
368
+ if not raw:
369
+ return None
370
+ text = raw.decode("utf-8", errors="replace")
371
+ try:
372
+ return json.loads(text)
373
+ except ValueError:
374
+ return text
ponk/errors.py ADDED
@@ -0,0 +1,163 @@
1
+ """Exceptions for the ponk public API.
2
+
3
+ Every error the API returns carries the same JSON envelope:
4
+
5
+ {"error": {"code": "...", "message": "...", "request_id": "..."}}
6
+
7
+ `error_from_response` turns one of those into the exception below that matches
8
+ the HTTP status. The `code` string is kept verbatim rather than parsed into an
9
+ enum: 422 can carry a risk code (a Token-2022 mint on Orca, a transfer-hook
10
+ mint) and inventing a closed set here would drop codes the server adds later.
11
+
12
+ Nothing is guessed. If the body is not the envelope (a proxy 502, an HTML error
13
+ page), `code` is `http_<status>` and `body` holds what actually arrived.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Mapping, Optional
19
+
20
+ __all__ = [
21
+ "PonkError",
22
+ "PonkTransportError",
23
+ "PonkAPIError",
24
+ "PonkBadRequestError",
25
+ "PonkAuthError",
26
+ "PonkForbiddenError",
27
+ "PonkNotFoundError",
28
+ "PonkConflictError",
29
+ "PonkRateLimitedError",
30
+ "PonkUnprocessableError",
31
+ "PonkServerError",
32
+ "error_from_response",
33
+ ]
34
+
35
+
36
+ class PonkError(Exception):
37
+ """Base class. Catch this to catch everything this client raises."""
38
+
39
+
40
+ class PonkTransportError(PonkError):
41
+ """The request never produced an HTTP response.
42
+
43
+ A DNS failure, a refused connection, a TLS failure or a timeout. A
44
+ fund-moving call that times out has NOT necessarily failed: the server
45
+ keeps running the work. See the README on retrying an exit.
46
+ """
47
+
48
+
49
+ class PonkAPIError(PonkError):
50
+ """The server answered with a non-2xx status."""
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ *,
56
+ status: int,
57
+ code: str,
58
+ request_id: Optional[str] = None,
59
+ body: Any = None,
60
+ ) -> None:
61
+ super().__init__(message)
62
+ self.message = message
63
+ self.status = status
64
+ self.code = code
65
+ self.request_id = request_id
66
+ self.body = body
67
+
68
+ def __str__(self) -> str:
69
+ parts = ["ponk api {0}: {1} ({2})".format(self.status, self.message, self.code)]
70
+ if self.request_id:
71
+ parts.append("request_id={0}".format(self.request_id))
72
+ return " ".join(parts)
73
+
74
+
75
+ class PonkBadRequestError(PonkAPIError):
76
+ """400. The request was malformed, or a strategy config did not validate.
77
+
78
+ `code` is `invalid_strategy_config` when the config did not match the
79
+ declared strategy; `message` names the offending field.
80
+ """
81
+
82
+
83
+ class PonkAuthError(PonkAPIError):
84
+ """401. Missing, malformed, unknown or revoked key.
85
+
86
+ Unknown and revoked are deliberately indistinguishable. Do not retry.
87
+ """
88
+
89
+
90
+ class PonkForbiddenError(PonkAPIError):
91
+ """403. The key is valid but read-only. Mint a `trade` key. Do not retry."""
92
+
93
+
94
+ class PonkNotFoundError(PonkAPIError):
95
+ """404. No such object, or it belongs to another account.
96
+
97
+ The two are deliberately indistinguishable, so a key cannot be used to
98
+ probe for another account's agents.
99
+ """
100
+
101
+
102
+ class PonkConflictError(PonkAPIError):
103
+ """409. Conflicts with in-flight work, for example an exit already running."""
104
+
105
+
106
+ class PonkRateLimitedError(PonkAPIError):
107
+ """429. Back off and retry."""
108
+
109
+
110
+ class PonkUnprocessableError(PonkAPIError):
111
+ """422. Understood but cannot apply.
112
+
113
+ Compounding an agent with no open position, or a venue capability the
114
+ platform refuses to execute. `code` carries the risk code when there is
115
+ one.
116
+ """
117
+
118
+
119
+ class PonkServerError(PonkAPIError):
120
+ """5xx. The message is deliberately generic; quote `request_id` to support."""
121
+
122
+
123
+ _BY_STATUS = {
124
+ 400: PonkBadRequestError,
125
+ 401: PonkAuthError,
126
+ 403: PonkForbiddenError,
127
+ 404: PonkNotFoundError,
128
+ 409: PonkConflictError,
129
+ 422: PonkUnprocessableError,
130
+ 429: PonkRateLimitedError,
131
+ }
132
+
133
+
134
+ def error_from_response(status: int, payload: Any) -> PonkAPIError:
135
+ """Build the exception for one non-2xx response.
136
+
137
+ `payload` is the decoded JSON body, or the raw text when it was not JSON.
138
+ """
139
+ code = "http_{0}".format(status)
140
+ message = "request failed with status {0}".format(status)
141
+ request_id = None
142
+
143
+ if isinstance(payload, Mapping):
144
+ envelope = payload.get("error")
145
+ if isinstance(envelope, Mapping):
146
+ code = str(envelope.get("code") or code)
147
+ raw_message = envelope.get("message")
148
+ if raw_message:
149
+ message = str(raw_message)
150
+ raw_request_id = envelope.get("request_id")
151
+ if raw_request_id:
152
+ request_id = str(raw_request_id)
153
+
154
+ cls = _BY_STATUS.get(status)
155
+ if cls is None:
156
+ cls = PonkServerError if status >= 500 else PonkAPIError
157
+ return cls(
158
+ message,
159
+ status=status,
160
+ code=code,
161
+ request_id=request_id,
162
+ body=payload,
163
+ )