crewlayer 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.
- crewlayer/__init__.py +54 -0
- crewlayer/_actions.py +153 -0
- crewlayer/_client.py +87 -0
- crewlayer/_context.py +107 -0
- crewlayer/_exceptions.py +71 -0
- crewlayer/_http.py +101 -0
- crewlayer/_memory.py +192 -0
- crewlayer/_types.py +242 -0
- crewlayer/integrations/__init__.py +6 -0
- crewlayer/integrations/autogen.py +538 -0
- crewlayer/integrations/crewai.py +215 -0
- crewlayer/integrations/langchain.py +404 -0
- crewlayer/integrations/llamaindex.py +527 -0
- crewlayer/py.typed +0 -0
- crewlayer-0.1.0.dist-info/METADATA +232 -0
- crewlayer-0.1.0.dist-info/RECORD +17 -0
- crewlayer-0.1.0.dist-info/WHEEL +4 -0
crewlayer/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""CrewLayer SDK — official Python client for the CrewLayer AI agent backend."""
|
|
2
|
+
from crewlayer._client import CrewLayerAsyncClient, CrewLayerClient
|
|
3
|
+
from crewlayer._exceptions import (
|
|
4
|
+
AuthError,
|
|
5
|
+
ConflictError,
|
|
6
|
+
CrewLayerError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
ServerError,
|
|
10
|
+
)
|
|
11
|
+
from crewlayer._types import (
|
|
12
|
+
ActionPage,
|
|
13
|
+
ActionRecord,
|
|
14
|
+
ActionStats,
|
|
15
|
+
ContextEntry,
|
|
16
|
+
ContextNamespace,
|
|
17
|
+
ExtractResult,
|
|
18
|
+
MemoryItem,
|
|
19
|
+
MemoryPage,
|
|
20
|
+
Message,
|
|
21
|
+
RecallResult,
|
|
22
|
+
ShortMemory,
|
|
23
|
+
ToolStat,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Clients
|
|
28
|
+
"CrewLayerClient",
|
|
29
|
+
"CrewLayerAsyncClient",
|
|
30
|
+
# Exceptions
|
|
31
|
+
"CrewLayerError",
|
|
32
|
+
"AuthError",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"ConflictError",
|
|
35
|
+
"RateLimitError",
|
|
36
|
+
"ServerError",
|
|
37
|
+
# Types — memory
|
|
38
|
+
"Message",
|
|
39
|
+
"ShortMemory",
|
|
40
|
+
"MemoryItem",
|
|
41
|
+
"RecallResult",
|
|
42
|
+
"ExtractResult",
|
|
43
|
+
"MemoryPage",
|
|
44
|
+
# Types — actions
|
|
45
|
+
"ActionRecord",
|
|
46
|
+
"ActionPage",
|
|
47
|
+
"ToolStat",
|
|
48
|
+
"ActionStats",
|
|
49
|
+
# Types — context
|
|
50
|
+
"ContextEntry",
|
|
51
|
+
"ContextNamespace",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
__version__ = "0.1.0"
|
crewlayer/_actions.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Actions resource clients — sync and async."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from crewlayer._http import AsyncTransport, SyncTransport
|
|
7
|
+
from crewlayer._types import ActionPage, ActionRecord, ActionStats
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ActionsClient:
|
|
11
|
+
"""Synchronous action log operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, http: SyncTransport) -> None:
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def log(
|
|
17
|
+
self,
|
|
18
|
+
agent_id: str,
|
|
19
|
+
tool_name: str,
|
|
20
|
+
input_params: dict[str, Any],
|
|
21
|
+
output_result: dict[str, Any],
|
|
22
|
+
status: str,
|
|
23
|
+
*,
|
|
24
|
+
session_id: str | None = None,
|
|
25
|
+
duration_ms: int | None = None,
|
|
26
|
+
error_msg: str | None = None,
|
|
27
|
+
metadata: dict[str, Any] | None = None,
|
|
28
|
+
) -> ActionRecord:
|
|
29
|
+
"""Record an immutable action entry for the agent."""
|
|
30
|
+
data = self._http.request(
|
|
31
|
+
"POST",
|
|
32
|
+
f"/v1/agents/{agent_id}/actions",
|
|
33
|
+
json={
|
|
34
|
+
"tool_name": tool_name,
|
|
35
|
+
"input_params": input_params,
|
|
36
|
+
"output_result": output_result,
|
|
37
|
+
"status": status,
|
|
38
|
+
"session_id": session_id,
|
|
39
|
+
"duration_ms": duration_ms,
|
|
40
|
+
"error_msg": error_msg,
|
|
41
|
+
"metadata": metadata or {},
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
return ActionRecord._from(data)
|
|
45
|
+
|
|
46
|
+
def get(self, agent_id: str, action_id: str) -> ActionRecord:
|
|
47
|
+
"""Retrieve a single action by ID."""
|
|
48
|
+
data = self._http.request("GET", f"/v1/agents/{agent_id}/actions/{action_id}")
|
|
49
|
+
return ActionRecord._from(data)
|
|
50
|
+
|
|
51
|
+
def list(
|
|
52
|
+
self,
|
|
53
|
+
agent_id: str,
|
|
54
|
+
*,
|
|
55
|
+
tool: str | None = None,
|
|
56
|
+
status: str | None = None,
|
|
57
|
+
since: str | None = None,
|
|
58
|
+
until: str | None = None,
|
|
59
|
+
limit: int = 50,
|
|
60
|
+
cursor: str | None = None,
|
|
61
|
+
) -> ActionPage:
|
|
62
|
+
"""List actions with optional filters. Paginated via cursor."""
|
|
63
|
+
params: dict[str, Any] = {"limit": limit}
|
|
64
|
+
if tool is not None:
|
|
65
|
+
params["tool"] = tool
|
|
66
|
+
if status is not None:
|
|
67
|
+
params["status"] = status
|
|
68
|
+
if since is not None:
|
|
69
|
+
params["since"] = since
|
|
70
|
+
if until is not None:
|
|
71
|
+
params["until"] = until
|
|
72
|
+
if cursor is not None:
|
|
73
|
+
params["cursor"] = cursor
|
|
74
|
+
data = self._http.request("GET", f"/v1/agents/{agent_id}/actions", params=params)
|
|
75
|
+
return ActionPage._from(data)
|
|
76
|
+
|
|
77
|
+
def stats(self, agent_id: str) -> ActionStats:
|
|
78
|
+
"""Aggregate statistics: totals, error rate, average duration, per-tool breakdown."""
|
|
79
|
+
data = self._http.request("GET", f"/v1/agents/{agent_id}/actions/stats")
|
|
80
|
+
return ActionStats._from(data)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AsyncActionsClient:
|
|
84
|
+
"""Asynchronous action log operations."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, http: AsyncTransport) -> None:
|
|
87
|
+
self._http = http
|
|
88
|
+
|
|
89
|
+
async def log(
|
|
90
|
+
self,
|
|
91
|
+
agent_id: str,
|
|
92
|
+
tool_name: str,
|
|
93
|
+
input_params: dict[str, Any],
|
|
94
|
+
output_result: dict[str, Any],
|
|
95
|
+
status: str,
|
|
96
|
+
*,
|
|
97
|
+
session_id: str | None = None,
|
|
98
|
+
duration_ms: int | None = None,
|
|
99
|
+
error_msg: str | None = None,
|
|
100
|
+
metadata: dict[str, Any] | None = None,
|
|
101
|
+
) -> ActionRecord:
|
|
102
|
+
"""Record an immutable action entry for the agent."""
|
|
103
|
+
data = await self._http.request(
|
|
104
|
+
"POST",
|
|
105
|
+
f"/v1/agents/{agent_id}/actions",
|
|
106
|
+
json={
|
|
107
|
+
"tool_name": tool_name,
|
|
108
|
+
"input_params": input_params,
|
|
109
|
+
"output_result": output_result,
|
|
110
|
+
"status": status,
|
|
111
|
+
"session_id": session_id,
|
|
112
|
+
"duration_ms": duration_ms,
|
|
113
|
+
"error_msg": error_msg,
|
|
114
|
+
"metadata": metadata or {},
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
return ActionRecord._from(data)
|
|
118
|
+
|
|
119
|
+
async def get(self, agent_id: str, action_id: str) -> ActionRecord:
|
|
120
|
+
"""Retrieve a single action by ID."""
|
|
121
|
+
data = await self._http.request("GET", f"/v1/agents/{agent_id}/actions/{action_id}")
|
|
122
|
+
return ActionRecord._from(data)
|
|
123
|
+
|
|
124
|
+
async def list(
|
|
125
|
+
self,
|
|
126
|
+
agent_id: str,
|
|
127
|
+
*,
|
|
128
|
+
tool: str | None = None,
|
|
129
|
+
status: str | None = None,
|
|
130
|
+
since: str | None = None,
|
|
131
|
+
until: str | None = None,
|
|
132
|
+
limit: int = 50,
|
|
133
|
+
cursor: str | None = None,
|
|
134
|
+
) -> ActionPage:
|
|
135
|
+
"""List actions with optional filters. Paginated via cursor."""
|
|
136
|
+
params: dict[str, Any] = {"limit": limit}
|
|
137
|
+
if tool is not None:
|
|
138
|
+
params["tool"] = tool
|
|
139
|
+
if status is not None:
|
|
140
|
+
params["status"] = status
|
|
141
|
+
if since is not None:
|
|
142
|
+
params["since"] = since
|
|
143
|
+
if until is not None:
|
|
144
|
+
params["until"] = until
|
|
145
|
+
if cursor is not None:
|
|
146
|
+
params["cursor"] = cursor
|
|
147
|
+
data = await self._http.request("GET", f"/v1/agents/{agent_id}/actions", params=params)
|
|
148
|
+
return ActionPage._from(data)
|
|
149
|
+
|
|
150
|
+
async def stats(self, agent_id: str) -> ActionStats:
|
|
151
|
+
"""Aggregate statistics: totals, error rate, average duration, per-tool breakdown."""
|
|
152
|
+
data = await self._http.request("GET", f"/v1/agents/{agent_id}/actions/stats")
|
|
153
|
+
return ActionStats._from(data)
|
crewlayer/_client.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Top-level CrewLayer clients — sync and async."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from crewlayer._actions import ActionsClient, AsyncActionsClient
|
|
7
|
+
from crewlayer._context import AsyncContextClient, ContextClient
|
|
8
|
+
from crewlayer._http import AsyncTransport, SyncTransport
|
|
9
|
+
from crewlayer._memory import AsyncMemoryClient, MemoryClient
|
|
10
|
+
|
|
11
|
+
_DEFAULT_BASE_URL = "http://localhost:8000"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CrewLayerClient:
|
|
15
|
+
"""Synchronous CrewLayer client.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
client = CrewLayerClient(api_key="crwl_...")
|
|
20
|
+
client.memory.append(agent_id="...", role="user", content="Hello")
|
|
21
|
+
result = client.memory.recall(agent_id="...", query="user preferences")
|
|
22
|
+
client.close()
|
|
23
|
+
|
|
24
|
+
As a context manager::
|
|
25
|
+
|
|
26
|
+
with CrewLayerClient(api_key="crwl_...") as client:
|
|
27
|
+
client.actions.log(agent_id="...", tool_name="send_email", ...)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
api_key: str,
|
|
33
|
+
*,
|
|
34
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._http = SyncTransport(api_key, base_url)
|
|
37
|
+
self.memory = MemoryClient(self._http)
|
|
38
|
+
self.actions = ActionsClient(self._http)
|
|
39
|
+
self.context = ContextClient(self._http)
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
"""Close the underlying HTTP connection pool."""
|
|
43
|
+
self._http.close()
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> CrewLayerClient:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, *_: Any) -> None:
|
|
49
|
+
self.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CrewLayerAsyncClient:
|
|
53
|
+
"""Asynchronous CrewLayer client.
|
|
54
|
+
|
|
55
|
+
Usage::
|
|
56
|
+
|
|
57
|
+
client = CrewLayerAsyncClient(api_key="crwl_...")
|
|
58
|
+
await client.memory.append(agent_id="...", role="user", content="Hello")
|
|
59
|
+
result = await client.memory.recall(agent_id="...", query="user preferences")
|
|
60
|
+
await client.aclose()
|
|
61
|
+
|
|
62
|
+
As an async context manager::
|
|
63
|
+
|
|
64
|
+
async with CrewLayerAsyncClient(api_key="crwl_...") as client:
|
|
65
|
+
await client.actions.log(agent_id="...", tool_name="send_email", ...)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
api_key: str,
|
|
71
|
+
*,
|
|
72
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._http = AsyncTransport(api_key, base_url)
|
|
75
|
+
self.memory = AsyncMemoryClient(self._http)
|
|
76
|
+
self.actions = AsyncActionsClient(self._http)
|
|
77
|
+
self.context = AsyncContextClient(self._http)
|
|
78
|
+
|
|
79
|
+
async def aclose(self) -> None:
|
|
80
|
+
"""Close the underlying HTTP connection pool."""
|
|
81
|
+
await self._http.aclose()
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> CrewLayerAsyncClient:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
87
|
+
await self.aclose()
|
crewlayer/_context.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Context (blackboard) resource clients — sync and async."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from crewlayer._http import AsyncTransport, SyncTransport
|
|
7
|
+
from crewlayer._types import ContextEntry, ContextNamespace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContextClient:
|
|
11
|
+
"""Synchronous shared blackboard operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, http: SyncTransport) -> None:
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def write(
|
|
17
|
+
self,
|
|
18
|
+
namespace: str,
|
|
19
|
+
key: str,
|
|
20
|
+
value: dict[str, Any],
|
|
21
|
+
*,
|
|
22
|
+
written_by: str | None = None,
|
|
23
|
+
expires_at: str | None = None,
|
|
24
|
+
expected_version: int | None = None,
|
|
25
|
+
) -> ContextEntry:
|
|
26
|
+
"""Write or overwrite a context entry.
|
|
27
|
+
|
|
28
|
+
Pass expected_version to enable optimistic locking:
|
|
29
|
+
- Use 0 to assert the key must not yet exist.
|
|
30
|
+
- Use the version you last read to prevent clobbering concurrent writes.
|
|
31
|
+
Raises ConflictError on mismatch.
|
|
32
|
+
"""
|
|
33
|
+
data = self._http.request(
|
|
34
|
+
"PUT",
|
|
35
|
+
f"/v1/context/{namespace}/{key}",
|
|
36
|
+
json={
|
|
37
|
+
"value": value,
|
|
38
|
+
"written_by": written_by,
|
|
39
|
+
"expires_at": expires_at,
|
|
40
|
+
"expected_version": expected_version,
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
return ContextEntry._from(data)
|
|
44
|
+
|
|
45
|
+
def read(self, namespace: str, key: str) -> ContextEntry:
|
|
46
|
+
"""Read a context entry. Raises NotFoundError if absent or expired."""
|
|
47
|
+
data = self._http.request("GET", f"/v1/context/{namespace}/{key}")
|
|
48
|
+
return ContextEntry._from(data)
|
|
49
|
+
|
|
50
|
+
def list_namespace(self, namespace: str) -> ContextNamespace:
|
|
51
|
+
"""List all non-expired entries in a namespace, ordered by key."""
|
|
52
|
+
data = self._http.request("GET", f"/v1/context/{namespace}")
|
|
53
|
+
return ContextNamespace._from(data)
|
|
54
|
+
|
|
55
|
+
def delete(self, namespace: str, key: str) -> None:
|
|
56
|
+
"""Delete a context entry. Raises NotFoundError if it does not exist."""
|
|
57
|
+
self._http.request("DELETE", f"/v1/context/{namespace}/{key}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AsyncContextClient:
|
|
61
|
+
"""Asynchronous shared blackboard operations."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, http: AsyncTransport) -> None:
|
|
64
|
+
self._http = http
|
|
65
|
+
|
|
66
|
+
async def write(
|
|
67
|
+
self,
|
|
68
|
+
namespace: str,
|
|
69
|
+
key: str,
|
|
70
|
+
value: dict[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
written_by: str | None = None,
|
|
73
|
+
expires_at: str | None = None,
|
|
74
|
+
expected_version: int | None = None,
|
|
75
|
+
) -> ContextEntry:
|
|
76
|
+
"""Write or overwrite a context entry.
|
|
77
|
+
|
|
78
|
+
Pass expected_version to enable optimistic locking:
|
|
79
|
+
- Use 0 to assert the key must not yet exist.
|
|
80
|
+
- Use the version you last read to prevent clobbering concurrent writes.
|
|
81
|
+
Raises ConflictError on mismatch.
|
|
82
|
+
"""
|
|
83
|
+
data = await self._http.request(
|
|
84
|
+
"PUT",
|
|
85
|
+
f"/v1/context/{namespace}/{key}",
|
|
86
|
+
json={
|
|
87
|
+
"value": value,
|
|
88
|
+
"written_by": written_by,
|
|
89
|
+
"expires_at": expires_at,
|
|
90
|
+
"expected_version": expected_version,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
return ContextEntry._from(data)
|
|
94
|
+
|
|
95
|
+
async def read(self, namespace: str, key: str) -> ContextEntry:
|
|
96
|
+
"""Read a context entry. Raises NotFoundError if absent or expired."""
|
|
97
|
+
data = await self._http.request("GET", f"/v1/context/{namespace}/{key}")
|
|
98
|
+
return ContextEntry._from(data)
|
|
99
|
+
|
|
100
|
+
async def list_namespace(self, namespace: str) -> ContextNamespace:
|
|
101
|
+
"""List all non-expired entries in a namespace, ordered by key."""
|
|
102
|
+
data = await self._http.request("GET", f"/v1/context/{namespace}")
|
|
103
|
+
return ContextNamespace._from(data)
|
|
104
|
+
|
|
105
|
+
async def delete(self, namespace: str, key: str) -> None:
|
|
106
|
+
"""Delete a context entry. Raises NotFoundError if it does not exist."""
|
|
107
|
+
await self._http.request("DELETE", f"/v1/context/{namespace}/{key}")
|
crewlayer/_exceptions.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Typed exception hierarchy for the CrewLayer SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CrewLayerError(Exception):
|
|
8
|
+
"""Base class for all CrewLayer SDK errors."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
message: str,
|
|
13
|
+
*,
|
|
14
|
+
status_code: int | None = None,
|
|
15
|
+
response: dict[str, Any] | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.response = response
|
|
20
|
+
|
|
21
|
+
def __repr__(self) -> str:
|
|
22
|
+
return f"{type(self).__name__}({self!s}, status_code={self.status_code})"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthError(CrewLayerError):
|
|
26
|
+
"""Raised on HTTP 401 / 403 — invalid or missing API key."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotFoundError(CrewLayerError):
|
|
30
|
+
"""Raised on HTTP 404 — resource not found."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConflictError(CrewLayerError):
|
|
34
|
+
"""Raised on HTTP 409 — optimistic locking version conflict."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RateLimitError(CrewLayerError):
|
|
38
|
+
"""Raised on HTTP 429 — request rate limit exceeded."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ServerError(CrewLayerError):
|
|
42
|
+
"""Raised on HTTP 5xx — transient server-side error (after retries exhausted)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_STATUS_MAP: dict[int, type[CrewLayerError]] = {
|
|
46
|
+
401: AuthError,
|
|
47
|
+
403: AuthError,
|
|
48
|
+
404: NotFoundError,
|
|
49
|
+
409: ConflictError,
|
|
50
|
+
429: RateLimitError,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def raise_for_response(response: Any) -> None: # response: httpx.Response
|
|
55
|
+
"""Raise the appropriate SDK exception if the response is not successful."""
|
|
56
|
+
if response.is_success:
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
body: dict[str, Any] = response.json()
|
|
60
|
+
detail: str = body.get("detail", response.text)
|
|
61
|
+
except Exception:
|
|
62
|
+
body = {}
|
|
63
|
+
detail = response.text
|
|
64
|
+
|
|
65
|
+
status = response.status_code
|
|
66
|
+
if status >= 500:
|
|
67
|
+
cls: type[CrewLayerError] = ServerError
|
|
68
|
+
else:
|
|
69
|
+
cls = _STATUS_MAP.get(status, CrewLayerError)
|
|
70
|
+
|
|
71
|
+
raise cls(detail, status_code=status, response=body)
|
crewlayer/_http.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""HTTP transports with automatic retry on transient 5xx errors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from crewlayer._exceptions import raise_for_response
|
|
11
|
+
|
|
12
|
+
_RETRY_ON = frozenset({500, 502, 503, 504})
|
|
13
|
+
_MAX_RETRIES = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncTransport:
|
|
17
|
+
"""Synchronous HTTP transport built on httpx.Client."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, api_key: str, base_url: str) -> None:
|
|
20
|
+
self._client = httpx.Client(
|
|
21
|
+
base_url=base_url.rstrip("/"),
|
|
22
|
+
headers={"X-API-Key": api_key},
|
|
23
|
+
timeout=30.0,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
27
|
+
"""Execute a request, retrying on 5xx with exponential backoff.
|
|
28
|
+
|
|
29
|
+
Backoff: 1 s, 2 s, 4 s (2^attempt seconds, up to _MAX_RETRIES retries).
|
|
30
|
+
Returns {} for 204 No Content responses.
|
|
31
|
+
"""
|
|
32
|
+
response: httpx.Response | None = None
|
|
33
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
34
|
+
try:
|
|
35
|
+
response = self._client.request(method, path, **kwargs)
|
|
36
|
+
except httpx.TransportError:
|
|
37
|
+
if attempt < _MAX_RETRIES:
|
|
38
|
+
time.sleep(2 ** attempt)
|
|
39
|
+
continue
|
|
40
|
+
raise
|
|
41
|
+
if response.status_code in _RETRY_ON and attempt < _MAX_RETRIES:
|
|
42
|
+
time.sleep(2 ** attempt)
|
|
43
|
+
continue
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
assert response is not None
|
|
47
|
+
raise_for_response(response)
|
|
48
|
+
return {} if response.status_code == 204 else response.json()
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
self._client.close()
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> SyncTransport:
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, *_: Any) -> None:
|
|
57
|
+
self.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AsyncTransport:
|
|
61
|
+
"""Asynchronous HTTP transport built on httpx.AsyncClient."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, api_key: str, base_url: str) -> None:
|
|
64
|
+
self._client = httpx.AsyncClient(
|
|
65
|
+
base_url=base_url.rstrip("/"),
|
|
66
|
+
headers={"X-API-Key": api_key},
|
|
67
|
+
timeout=30.0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
71
|
+
"""Execute a request, retrying on 5xx with exponential backoff (async sleep).
|
|
72
|
+
|
|
73
|
+
Backoff: 1 s, 2 s, 4 s (2^attempt seconds, up to _MAX_RETRIES retries).
|
|
74
|
+
Returns {} for 204 No Content responses.
|
|
75
|
+
"""
|
|
76
|
+
response: httpx.Response | None = None
|
|
77
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
78
|
+
try:
|
|
79
|
+
response = await self._client.request(method, path, **kwargs)
|
|
80
|
+
except httpx.TransportError:
|
|
81
|
+
if attempt < _MAX_RETRIES:
|
|
82
|
+
await asyncio.sleep(2 ** attempt)
|
|
83
|
+
continue
|
|
84
|
+
raise
|
|
85
|
+
if response.status_code in _RETRY_ON and attempt < _MAX_RETRIES:
|
|
86
|
+
await asyncio.sleep(2 ** attempt)
|
|
87
|
+
continue
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
assert response is not None
|
|
91
|
+
raise_for_response(response)
|
|
92
|
+
return {} if response.status_code == 204 else response.json()
|
|
93
|
+
|
|
94
|
+
async def aclose(self) -> None:
|
|
95
|
+
await self._client.aclose()
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> AsyncTransport:
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
101
|
+
await self.aclose()
|