server4agent 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.
@@ -0,0 +1,567 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from typing import Callable, List, Optional, Sequence
6
+
7
+ from .. import _transport as _t
8
+ from .._models import (
9
+ ApiKey,
10
+ BUILD_TERMINAL_STATES,
11
+ CreatedApiKey,
12
+ CreatedWebhook,
13
+ Deployment,
14
+ ProjectTemplate,
15
+ ServerAction,
16
+ ServerTier,
17
+ TASK_TERMINAL_STATES,
18
+ Webhook,
19
+ _Record,
20
+ parse_build,
21
+ parse_project,
22
+ parse_server,
23
+ parse_task,
24
+ )
25
+ from .._transport import quote_segment as _seg
26
+ from ..errors import APITimeoutError
27
+ from .transport import SyncTransport
28
+
29
+
30
+ # =============================================================================
31
+ # Handles — returned by create()/get(); data records you can also act on.
32
+ # =============================================================================
33
+
34
+ class Server(_Record):
35
+ id: str
36
+ task: str
37
+ tier: ServerTier
38
+ status: str
39
+ region: str
40
+ credits_used: float
41
+ storage_used_gb: float
42
+ public_url: Optional[str]
43
+ created_at: Optional[str]
44
+
45
+ def __init__(self, transport: SyncTransport, raw: dict) -> None:
46
+ self._t = transport
47
+ self.__dict__.update(parse_server(raw))
48
+
49
+ # scoped sub-resources (properties, so they stay out of repr) --------------
50
+ @property
51
+ def projects(self) -> "_ScopedProjects":
52
+ return _ScopedProjects(self._t, self.id)
53
+
54
+ @property
55
+ def tasks(self) -> "_ScopedTasks":
56
+ return _ScopedTasks(self._t, self.id)
57
+
58
+ @property
59
+ def builds(self) -> "_ScopedBuilds":
60
+ return _ScopedBuilds(self._t, self.id)
61
+
62
+ @property
63
+ def files(self) -> "_ScopedFiles":
64
+ return _ScopedFiles(self._t, self.id)
65
+
66
+ # actions ------------------------------------------------------------------
67
+ def refresh(self) -> "Server":
68
+ r = self._t.request("GET", f"/servers/{_seg(self.id)}")
69
+ self.__dict__.update(parse_server(r["server"]))
70
+ return self
71
+
72
+ def update(self, *, task: Optional[str] = None, region: Optional[str] = None) -> "Server":
73
+ body = {k: v for k, v in {"task": task, "region": region}.items() if v is not None}
74
+ r = self._t.request("PATCH", f"/servers/{_seg(self.id)}", body=body)
75
+ self.__dict__.update(parse_server(r["server"]))
76
+ return self
77
+
78
+ def delete(self) -> None:
79
+ self._t.request("DELETE", f"/servers/{_seg(self.id)}")
80
+
81
+ def control(self, action: ServerAction) -> dict:
82
+ return self._t.request("POST", f"/servers/{_seg(self.id)}/actions", body={"action": action})
83
+
84
+ def start(self) -> dict:
85
+ return self.control("start")
86
+
87
+ def stop(self) -> dict:
88
+ return self.control("stop")
89
+
90
+ def restart(self) -> dict:
91
+ return self.control("restart")
92
+
93
+ def exec(self, command: str) -> str:
94
+ """Run a shell command on the server. Returns stdout."""
95
+ r = self._t.request("POST", f"/servers/{_seg(self.id)}/exec", body={"command": command})
96
+ return r["stdout"]
97
+
98
+ def deploy(self) -> str:
99
+ """Publish the current workspace to a live public URL. Returns the URL."""
100
+ r = self._t.request("POST", f"/servers/{_seg(self.id)}/deployments")
101
+ return r["url"]
102
+
103
+ def deployments(self) -> List[Deployment]:
104
+ r = self._t.request("GET", f"/servers/{_seg(self.id)}/deployments")
105
+ return [Deployment(**d) for d in r["deployments"]]
106
+
107
+
108
+ class Project(_Record):
109
+ id: str
110
+ server_id: str
111
+ name: str
112
+ slug: str
113
+ description: Optional[str]
114
+ visibility: str
115
+ lifecycle: str
116
+ runtime_status: str
117
+ template: Optional[str]
118
+ url: Optional[str]
119
+ created_at: str
120
+ updated_at: str
121
+
122
+ def __init__(self, transport: SyncTransport, raw: dict) -> None:
123
+ self._t = transport
124
+ self.__dict__.update(parse_project(raw))
125
+
126
+ def refresh(self) -> "Project":
127
+ r = self._t.request("GET", f"/projects/{_seg(self.id)}")
128
+ self.__dict__.update(parse_project(r))
129
+ return self
130
+
131
+ def update(
132
+ self,
133
+ *,
134
+ name: Optional[str] = None,
135
+ description: Optional[str] = None,
136
+ visibility: Optional[str] = None,
137
+ lifecycle: Optional[str] = None,
138
+ ) -> "Project":
139
+ body = {
140
+ k: v
141
+ for k, v in {
142
+ "name": name,
143
+ "description": description,
144
+ "visibility": visibility,
145
+ "lifecycle": lifecycle,
146
+ }.items()
147
+ if v is not None
148
+ }
149
+ r = self._t.request("PATCH", f"/projects/{_seg(self.id)}", body=body)
150
+ self.__dict__.update(parse_project(r))
151
+ return self
152
+
153
+ def delete(self) -> None:
154
+ self._t.request("DELETE", f"/projects/{_seg(self.id)}")
155
+
156
+ def cleanup(self) -> None:
157
+ """Ephemeral projects only. Use ``delete()`` for persistent ones."""
158
+ self._t.request("POST", f"/projects/{_seg(self.id)}/cleanup")
159
+
160
+
161
+ class Task(_Record):
162
+ id: str
163
+ server_id: str
164
+ goal: str
165
+ status: str
166
+ created_at: str
167
+ result: Optional[str]
168
+ question: Optional[str]
169
+
170
+ def __init__(self, transport: SyncTransport, raw: dict) -> None:
171
+ self._t = transport
172
+ self.__dict__.update(parse_task(raw))
173
+
174
+ def refresh(self) -> "Task":
175
+ r = self._t.request("GET", f"/servers/{_seg(self.server_id)}/tasks/{_seg(self.id)}")
176
+ self.__dict__.update(parse_task(r["task"]))
177
+ return self
178
+
179
+ def wait(
180
+ self,
181
+ *,
182
+ poll_interval: float = 2.0,
183
+ timeout: float = 300.0,
184
+ on_poll: Optional[Callable[["Task"], None]] = None,
185
+ ) -> "Task":
186
+ """Block until the task reaches a terminal state (done, failed, cancelled,
187
+ or awaiting_input). Raises ``APITimeoutError`` if ``timeout`` elapses first.
188
+
189
+ ``on_poll`` is called with ``self`` after each refresh — handy for
190
+ streaming progress to a log or UI."""
191
+ deadline = time.monotonic() + timeout
192
+ while self.status not in TASK_TERMINAL_STATES:
193
+ if time.monotonic() >= deadline:
194
+ raise APITimeoutError(f"Task {self.id} still {self.status!r} after {timeout}s.")
195
+ time.sleep(poll_interval)
196
+ self.refresh()
197
+ if on_poll is not None:
198
+ on_poll(self)
199
+ return self
200
+
201
+
202
+ class Build(_Record):
203
+ id: str
204
+ server_id: str
205
+ goal: str
206
+ status: str
207
+ steps: list
208
+ artifacts: list
209
+ url: Optional[str]
210
+ created_at: str
211
+
212
+ def __init__(self, transport: SyncTransport, raw: dict) -> None:
213
+ self._t = transport
214
+ self.__dict__.update(parse_build(raw))
215
+
216
+ def refresh(self) -> "Build":
217
+ r = self._t.request("GET", f"/servers/{_seg(self.server_id)}/builds/{_seg(self.id)}")
218
+ self.__dict__.update(parse_build(r["build"]))
219
+ return self
220
+
221
+ def wait(
222
+ self,
223
+ *,
224
+ poll_interval: float = 2.0,
225
+ timeout: float = 600.0,
226
+ on_poll: Optional[Callable[["Build"], None]] = None,
227
+ ) -> "Build":
228
+ """Block until the build completes. Raises ``APITimeoutError`` on timeout.
229
+
230
+ ``on_poll`` is called with ``self`` after each refresh — handy for
231
+ streaming build-step progress."""
232
+ deadline = time.monotonic() + timeout
233
+ while self.status not in BUILD_TERMINAL_STATES:
234
+ if time.monotonic() >= deadline:
235
+ raise APITimeoutError(f"Build {self.id} still {self.status!r} after {timeout}s.")
236
+ time.sleep(poll_interval)
237
+ self.refresh()
238
+ if on_poll is not None:
239
+ on_poll(self)
240
+ return self
241
+
242
+
243
+ # =============================================================================
244
+ # Scoped sub-resources — reached via a Server handle (server.tasks, ...).
245
+ # =============================================================================
246
+
247
+ class _ScopedProjects:
248
+ def __init__(self, transport: SyncTransport, server_id: str) -> None:
249
+ self._t = transport
250
+ self._sid = server_id
251
+
252
+ def list(self) -> List[Project]:
253
+ r = self._t.request("GET", f"/servers/{_seg(self._sid)}/projects")
254
+ return [Project(self._t, p) for p in r["projects"]]
255
+
256
+ def create(
257
+ self,
258
+ *,
259
+ name: Optional[str] = None,
260
+ description: Optional[str] = None,
261
+ visibility: Optional[str] = None,
262
+ lifecycle: Optional[str] = None,
263
+ template: Optional[str] = None,
264
+ ) -> Project:
265
+ body = {
266
+ k: v
267
+ for k, v in {
268
+ "name": name,
269
+ "description": description,
270
+ "visibility": visibility,
271
+ "lifecycle": lifecycle,
272
+ "template": template,
273
+ }.items()
274
+ if v is not None
275
+ }
276
+ r = self._t.request("POST", f"/servers/{_seg(self._sid)}/projects", body=body)
277
+ return Project(self._t, r)
278
+
279
+
280
+ class _ScopedTasks:
281
+ def __init__(self, transport: SyncTransport, server_id: str) -> None:
282
+ self._t = transport
283
+ self._sid = server_id
284
+
285
+ def create(self, goal: str) -> Task:
286
+ r = self._t.request("POST", f"/servers/{_seg(self._sid)}/tasks", body={"goal": goal})
287
+ return Task(self._t, {"id": r["task_id"], "serverId": self._sid, "goal": goal, "status": r["status"], "createdAt": r.get("createdAt", "")})
288
+
289
+ def list(self) -> List[Task]:
290
+ r = self._t.request("GET", f"/servers/{_seg(self._sid)}/tasks")
291
+ return [Task(self._t, x) for x in r["tasks"]]
292
+
293
+ def get(self, task_id: str) -> Task:
294
+ r = self._t.request("GET", f"/servers/{_seg(self._sid)}/tasks/{_seg(task_id)}")
295
+ return Task(self._t, r["task"])
296
+
297
+
298
+ class _ScopedBuilds:
299
+ def __init__(self, transport: SyncTransport, server_id: str) -> None:
300
+ self._t = transport
301
+ self._sid = server_id
302
+
303
+ def start(self, goal: str) -> Build:
304
+ r = self._t.request("POST", f"/servers/{_seg(self._sid)}/builds", body={"goal": goal})
305
+ return Build(self._t, {"id": r["build_id"], "serverId": self._sid, "goal": goal, "status": r["status"], "steps": r.get("steps", []), "createdAt": r.get("createdAt", "")})
306
+
307
+ def list(self) -> List[Build]:
308
+ r = self._t.request("GET", f"/servers/{_seg(self._sid)}/builds")
309
+ return [Build(self._t, x) for x in r["builds"]]
310
+
311
+ def get(self, build_id: str) -> Build:
312
+ r = self._t.request("GET", f"/servers/{_seg(self._sid)}/builds/{_seg(build_id)}")
313
+ return Build(self._t, r["build"])
314
+
315
+
316
+ class _ScopedFiles:
317
+ def __init__(self, transport: SyncTransport, server_id: str) -> None:
318
+ self._t = transport
319
+ self._sid = server_id
320
+
321
+ def list(self) -> List[str]:
322
+ r = self._t.request("GET", f"/servers/{_seg(self._sid)}/files")
323
+ return r["files"]
324
+
325
+ def read(self, path: str) -> str:
326
+ r = self._t.request("GET", f"/servers/{_seg(self._sid)}/files", query={"path": path})
327
+ return r["content"]
328
+
329
+ def write(self, path: str, content: str) -> None:
330
+ self._t.request("PUT", f"/servers/{_seg(self._sid)}/files", body={"path": path, "content": content})
331
+
332
+ def delete(self, path: str) -> None:
333
+ self._t.request("DELETE", f"/servers/{_seg(self._sid)}/files", query={"path": path})
334
+
335
+
336
+ # =============================================================================
337
+ # Top-level resources — the flat client.servers.* API.
338
+ # =============================================================================
339
+
340
+ class ServersResource:
341
+ def __init__(self, transport: SyncTransport) -> None:
342
+ self._t = transport
343
+
344
+ def create(
345
+ self, *, task: Optional[str] = None, tier: Optional[ServerTier] = None, region: Optional[str] = None
346
+ ) -> Server:
347
+ body = {k: v for k, v in {"task": task, "tier": tier, "region": region}.items() if v is not None}
348
+ r = self._t.request("POST", "/servers", body=body)
349
+ # create returns a partial shape ({id,url,status,tier}); normalize to a Server.
350
+ return Server(self._t, {
351
+ "id": r["id"], "task": task or "", "tier": r["tier"], "status": r["status"],
352
+ "region": region or "", "publicUrl": r.get("url"),
353
+ })
354
+
355
+ def list(self) -> List[Server]:
356
+ r = self._t.request("GET", "/servers")
357
+ return [Server(self._t, s) for s in r["servers"]]
358
+
359
+ def get(self, server_id: str) -> Server:
360
+ r = self._t.request("GET", f"/servers/{_seg(server_id)}")
361
+ return Server(self._t, r["server"])
362
+
363
+ def update(self, server_id: str, *, task: Optional[str] = None, region: Optional[str] = None) -> Server:
364
+ body = {k: v for k, v in {"task": task, "region": region}.items() if v is not None}
365
+ r = self._t.request("PATCH", f"/servers/{_seg(server_id)}", body=body)
366
+ return Server(self._t, r["server"])
367
+
368
+ def delete(self, server_id: str) -> None:
369
+ self._t.request("DELETE", f"/servers/{_seg(server_id)}")
370
+
371
+ def control(self, server_id: str, action: ServerAction) -> dict:
372
+ return self._t.request("POST", f"/servers/{_seg(server_id)}/actions", body={"action": action})
373
+
374
+ def exec(self, server_id: str, command: str) -> str:
375
+ r = self._t.request("POST", f"/servers/{_seg(server_id)}/exec", body={"command": command})
376
+ return r["stdout"]
377
+
378
+ def deploy(self, server_id: str) -> str:
379
+ r = self._t.request("POST", f"/servers/{_seg(server_id)}/deployments")
380
+ return r["url"]
381
+
382
+ def list_deployments(self, server_id: str) -> List[Deployment]:
383
+ r = self._t.request("GET", f"/servers/{_seg(server_id)}/deployments")
384
+ return [Deployment(**d) for d in r["deployments"]]
385
+
386
+
387
+ class ProjectsResource:
388
+ def __init__(self, transport: SyncTransport) -> None:
389
+ self._t = transport
390
+
391
+ def list(self) -> List[Project]:
392
+ r = self._t.request("GET", "/projects")
393
+ return [Project(self._t, p) for p in r["projects"]]
394
+
395
+ def list_for_server(self, server_id: str) -> List[Project]:
396
+ return _ScopedProjects(self._t, server_id).list()
397
+
398
+ def create(self, server_id: str, **opts) -> Project:
399
+ return _ScopedProjects(self._t, server_id).create(**opts)
400
+
401
+ def get(self, project_id: str) -> Project:
402
+ r = self._t.request("GET", f"/projects/{_seg(project_id)}")
403
+ return Project(self._t, r)
404
+
405
+ def update(self, project_id: str, **patch) -> Project:
406
+ body = {k: v for k, v in patch.items() if v is not None}
407
+ r = self._t.request("PATCH", f"/projects/{_seg(project_id)}", body=body)
408
+ return Project(self._t, r)
409
+
410
+ def delete(self, project_id: str) -> None:
411
+ self._t.request("DELETE", f"/projects/{_seg(project_id)}")
412
+
413
+ def cleanup(self, project_id: str) -> None:
414
+ self._t.request("POST", f"/projects/{_seg(project_id)}/cleanup")
415
+
416
+
417
+ class TemplatesResource:
418
+ def __init__(self, transport: SyncTransport) -> None:
419
+ self._t = transport
420
+
421
+ def list(self) -> List[ProjectTemplate]:
422
+ r = self._t.request("GET", "/templates")
423
+ return [ProjectTemplate.parse(x) for x in r["templates"]]
424
+
425
+
426
+ class TasksResource:
427
+ def __init__(self, transport: SyncTransport) -> None:
428
+ self._t = transport
429
+
430
+ def create(self, server_id: str, goal: str) -> Task:
431
+ return _ScopedTasks(self._t, server_id).create(goal)
432
+
433
+ def list(self, server_id: str) -> List[Task]:
434
+ return _ScopedTasks(self._t, server_id).list()
435
+
436
+ def get(self, server_id: str, task_id: str) -> Task:
437
+ return _ScopedTasks(self._t, server_id).get(task_id)
438
+
439
+
440
+ class BuildsResource:
441
+ def __init__(self, transport: SyncTransport) -> None:
442
+ self._t = transport
443
+
444
+ def start(self, server_id: str, goal: str) -> Build:
445
+ return _ScopedBuilds(self._t, server_id).start(goal)
446
+
447
+ def list(self, server_id: str) -> List[Build]:
448
+ return _ScopedBuilds(self._t, server_id).list()
449
+
450
+ def get(self, server_id: str, build_id: str) -> Build:
451
+ return _ScopedBuilds(self._t, server_id).get(build_id)
452
+
453
+
454
+ class FilesResource:
455
+ def __init__(self, transport: SyncTransport) -> None:
456
+ self._t = transport
457
+
458
+ def list(self, server_id: str) -> List[str]:
459
+ return _ScopedFiles(self._t, server_id).list()
460
+
461
+ def read(self, server_id: str, path: str) -> str:
462
+ return _ScopedFiles(self._t, server_id).read(path)
463
+
464
+ def write(self, server_id: str, path: str, content: str) -> None:
465
+ _ScopedFiles(self._t, server_id).write(path, content)
466
+
467
+ def delete(self, server_id: str, path: str) -> None:
468
+ _ScopedFiles(self._t, server_id).delete(path)
469
+
470
+
471
+ class KeysResource:
472
+ def __init__(self, transport: SyncTransport) -> None:
473
+ self._t = transport
474
+
475
+ def list(self) -> List[ApiKey]:
476
+ r = self._t.request("GET", "/keys")
477
+ return [ApiKey.parse(k) for k in r["keys"]]
478
+
479
+ def create(self, label: str) -> CreatedApiKey:
480
+ r = self._t.request("POST", "/keys", body={"label": label})
481
+ base = ApiKey.parse(r)
482
+ return CreatedApiKey(key=r["key"], **base.__dict__)
483
+
484
+ def update_scope(
485
+ self,
486
+ key_id: str,
487
+ *,
488
+ allowed_server_ids: Optional[Sequence[str]] = None,
489
+ allowed_project_ids: Optional[Sequence[str]] = None,
490
+ ) -> dict:
491
+ body = {
492
+ "allowed_server_ids": list(allowed_server_ids) if allowed_server_ids else [],
493
+ "allowed_project_ids": list(allowed_project_ids) if allowed_project_ids else [],
494
+ }
495
+ return self._t.request("PATCH", f"/keys/{_seg(key_id)}", body=body)
496
+
497
+ def revoke(self, key_id: str) -> None:
498
+ self._t.request("DELETE", f"/keys/{_seg(key_id)}")
499
+
500
+
501
+ class WebhooksResource:
502
+ def __init__(self, transport: SyncTransport) -> None:
503
+ self._t = transport
504
+
505
+ def list(self) -> List[Webhook]:
506
+ r = self._t.request("GET", "/webhooks")
507
+ return [Webhook.parse(w) for w in r["webhooks"]]
508
+
509
+ def create(self, url: str, events: Optional[Sequence[str]] = None) -> CreatedWebhook:
510
+ body = {"url": url, "events": list(events) if events is not None else None}
511
+ r = self._t.request("POST", "/webhooks", body=body)
512
+ base = Webhook.parse(r)
513
+ return CreatedWebhook(secret=r["secret"], **base.__dict__)
514
+
515
+ def delete(self, webhook_id: str) -> None:
516
+ self._t.request("DELETE", f"/webhooks/{_seg(webhook_id)}")
517
+
518
+
519
+ class Server4Agent:
520
+ """Client for the Server4Agent REST API.
521
+
522
+ This SDK is for the code *around* your agent: your backend, a CI job, a
523
+ webhook receiver, an admin script. If your agent itself does tool-calling,
524
+ point it at the MCP server directly — it doesn't need this.
525
+
526
+ from server4agent import Server4Agent
527
+
528
+ client = Server4Agent() # reads SERVER4AGENT_API_KEY
529
+ server = client.servers.create(tier="small")
530
+ build = server.builds.start("a FastAPI todo API").wait()
531
+ print(build.url)
532
+
533
+ Usable as a context manager to close the underlying connection pool.
534
+ """
535
+
536
+ def __init__(
537
+ self,
538
+ api_key: Optional[str] = None,
539
+ *,
540
+ base_url: str = _t.DEFAULT_BASE_URL,
541
+ timeout: float = _t.DEFAULT_TIMEOUT,
542
+ max_retries: int = _t.DEFAULT_MAX_RETRIES,
543
+ ) -> None:
544
+ key = api_key or os.environ.get("SERVER4AGENT_API_KEY")
545
+ if not key:
546
+ raise ValueError(
547
+ "Server4Agent: no API key. Pass api_key=... or set SERVER4AGENT_API_KEY."
548
+ )
549
+ self._t = SyncTransport(base_url, key, timeout=timeout, max_retries=max_retries)
550
+
551
+ self.servers = ServersResource(self._t)
552
+ self.projects = ProjectsResource(self._t)
553
+ self.templates = TemplatesResource(self._t)
554
+ self.tasks = TasksResource(self._t)
555
+ self.builds = BuildsResource(self._t)
556
+ self.files = FilesResource(self._t)
557
+ self.keys = KeysResource(self._t)
558
+ self.webhooks = WebhooksResource(self._t)
559
+
560
+ def close(self) -> None:
561
+ self._t.close()
562
+
563
+ def __enter__(self) -> "Server4Agent":
564
+ return self
565
+
566
+ def __exit__(self, *exc) -> None:
567
+ self.close()
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Mapping, Optional
5
+
6
+ import httpx
7
+
8
+ from .. import _transport as t
9
+ from ..errors import APIConnectionError, APITimeoutError
10
+
11
+
12
+ class SyncTransport:
13
+ """Blocking HTTP with timeouts, method-aware retries, and typed errors."""
14
+
15
+ def __init__(
16
+ self,
17
+ base_url: str,
18
+ api_key: str,
19
+ *,
20
+ timeout: float = t.DEFAULT_TIMEOUT,
21
+ max_retries: int = t.DEFAULT_MAX_RETRIES,
22
+ client: Optional[httpx.Client] = None,
23
+ ) -> None:
24
+ self.max_retries = max_retries
25
+ self._client = client or httpx.Client(
26
+ base_url=base_url.rstrip("/"),
27
+ timeout=timeout,
28
+ headers=t.default_headers(api_key),
29
+ )
30
+
31
+ def request(
32
+ self,
33
+ method: str,
34
+ path: str,
35
+ *,
36
+ query: Optional[Mapping[str, Any]] = None,
37
+ body: Any = None,
38
+ ) -> Any:
39
+ params = t.clean_params(query)
40
+ attempt = 0
41
+ while True:
42
+ t.logger.debug("--> %s %s", method, path)
43
+ started = time.perf_counter()
44
+ try:
45
+ resp = self._client.request(method, path, params=params, json=body)
46
+ except httpx.TimeoutException:
47
+ if t.can_retry_exception(method, attempt, self.max_retries):
48
+ time.sleep(t.retry_delay(attempt, None))
49
+ attempt += 1
50
+ continue
51
+ raise APITimeoutError() from None
52
+ except httpx.TransportError as exc:
53
+ if t.can_retry_exception(method, attempt, self.max_retries):
54
+ time.sleep(t.retry_delay(attempt, None))
55
+ attempt += 1
56
+ continue
57
+ raise APIConnectionError(str(exc)) from None
58
+
59
+ elapsed_ms = (time.perf_counter() - started) * 1000
60
+ t.logger.debug("<-- %s %s %s (%.0fms)", method, path, resp.status_code, elapsed_ms)
61
+
62
+ if t.should_retry_status(method, resp.status_code, attempt, self.max_retries):
63
+ delay = t.retry_delay(attempt, resp.headers.get("retry-after"))
64
+ t.logger.debug("... retrying in %.2fs (attempt %d)", delay, attempt + 1)
65
+ time.sleep(delay)
66
+ attempt += 1
67
+ continue
68
+
69
+ data = t.decode_json(resp.content)
70
+ if resp.status_code >= 400:
71
+ t.raise_for_status(resp.status_code, data, resp.headers)
72
+ return data
73
+
74
+ def close(self) -> None:
75
+ self._client.close()