jararaca 0.3.11a16__py3-none-any.whl → 0.4.0a19__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.
Files changed (96) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +189 -17
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +4 -0
  5. jararaca/broker_backend/mapper.py +4 -0
  6. jararaca/broker_backend/redis_broker_backend.py +9 -3
  7. jararaca/cli.py +915 -51
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +8 -0
  11. jararaca/core/uow.py +41 -7
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/helpers/__init__.py +3 -0
  15. jararaca/helpers/global_scheduler/__init__.py +3 -0
  16. jararaca/helpers/global_scheduler/config.py +21 -0
  17. jararaca/helpers/global_scheduler/controller.py +42 -0
  18. jararaca/helpers/global_scheduler/registry.py +32 -0
  19. jararaca/lifecycle.py +6 -2
  20. jararaca/messagebus/__init__.py +4 -0
  21. jararaca/messagebus/bus_message_controller.py +4 -0
  22. jararaca/messagebus/consumers/__init__.py +3 -0
  23. jararaca/messagebus/decorators.py +121 -61
  24. jararaca/messagebus/implicit_headers.py +49 -0
  25. jararaca/messagebus/interceptors/__init__.py +3 -0
  26. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +62 -11
  27. jararaca/messagebus/interceptors/message_publisher_collector.py +62 -0
  28. jararaca/messagebus/interceptors/publisher_interceptor.py +29 -3
  29. jararaca/messagebus/message.py +4 -0
  30. jararaca/messagebus/publisher.py +6 -0
  31. jararaca/messagebus/worker.py +1002 -459
  32. jararaca/microservice.py +113 -2
  33. jararaca/observability/constants.py +7 -0
  34. jararaca/observability/decorators.py +170 -13
  35. jararaca/observability/fastapi_exception_handler.py +37 -0
  36. jararaca/observability/hooks.py +109 -0
  37. jararaca/observability/interceptor.py +4 -0
  38. jararaca/observability/providers/__init__.py +3 -0
  39. jararaca/observability/providers/otel.py +225 -16
  40. jararaca/persistence/base.py +39 -3
  41. jararaca/persistence/exports.py +4 -0
  42. jararaca/persistence/interceptors/__init__.py +3 -0
  43. jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
  44. jararaca/persistence/interceptors/constants.py +5 -0
  45. jararaca/persistence/interceptors/decorators.py +50 -0
  46. jararaca/persistence/session.py +3 -0
  47. jararaca/persistence/sort_filter.py +4 -0
  48. jararaca/persistence/utilities.py +73 -20
  49. jararaca/presentation/__init__.py +3 -0
  50. jararaca/presentation/decorators.py +88 -86
  51. jararaca/presentation/exceptions.py +23 -0
  52. jararaca/presentation/hooks.py +4 -0
  53. jararaca/presentation/http_microservice.py +4 -0
  54. jararaca/presentation/server.py +97 -45
  55. jararaca/presentation/websocket/__init__.py +3 -0
  56. jararaca/presentation/websocket/base_types.py +4 -0
  57. jararaca/presentation/websocket/context.py +4 -0
  58. jararaca/presentation/websocket/decorators.py +8 -41
  59. jararaca/presentation/websocket/redis.py +280 -53
  60. jararaca/presentation/websocket/types.py +4 -0
  61. jararaca/presentation/websocket/websocket_interceptor.py +46 -19
  62. jararaca/reflect/__init__.py +3 -0
  63. jararaca/reflect/controller_inspect.py +16 -10
  64. jararaca/reflect/decorators.py +252 -0
  65. jararaca/reflect/helpers.py +18 -0
  66. jararaca/reflect/metadata.py +34 -25
  67. jararaca/rpc/__init__.py +3 -0
  68. jararaca/rpc/http/__init__.py +101 -0
  69. jararaca/rpc/http/backends/__init__.py +14 -0
  70. jararaca/rpc/http/backends/httpx.py +43 -9
  71. jararaca/rpc/http/backends/otel.py +4 -0
  72. jararaca/rpc/http/decorators.py +380 -115
  73. jararaca/rpc/http/httpx.py +3 -0
  74. jararaca/scheduler/__init__.py +3 -0
  75. jararaca/scheduler/beat_worker.py +521 -105
  76. jararaca/scheduler/decorators.py +15 -22
  77. jararaca/scheduler/types.py +4 -0
  78. jararaca/tools/app_config/__init__.py +3 -0
  79. jararaca/tools/app_config/decorators.py +7 -19
  80. jararaca/tools/app_config/interceptor.py +6 -2
  81. jararaca/tools/typescript/__init__.py +3 -0
  82. jararaca/tools/typescript/decorators.py +120 -0
  83. jararaca/tools/typescript/interface_parser.py +1077 -174
  84. jararaca/utils/__init__.py +3 -0
  85. jararaca/utils/env_parse_utils.py +133 -0
  86. jararaca/utils/rabbitmq_utils.py +112 -39
  87. jararaca/utils/retry.py +19 -14
  88. jararaca-0.4.0a19.dist-info/LICENSE +674 -0
  89. jararaca-0.4.0a19.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  90. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/METADATA +12 -7
  91. jararaca-0.4.0a19.dist-info/RECORD +96 -0
  92. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/WHEEL +1 -1
  93. pyproject.toml +132 -0
  94. jararaca-0.3.11a16.dist-info/RECORD +0 -74
  95. /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
  96. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/entry_points.txt +0 -0
@@ -1,45 +1,51 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import asyncio
1
6
  import inspect
7
+ import json
8
+ import logging
9
+ import time
2
10
  from dataclasses import dataclass
3
11
  from typing import (
4
12
  Any,
5
13
  Awaitable,
6
14
  Callable,
15
+ Dict,
7
16
  Iterable,
8
17
  Literal,
18
+ Optional,
9
19
  Protocol,
10
- Type,
11
20
  TypeVar,
12
21
  cast,
13
22
  )
14
23
 
15
24
  from pydantic import BaseModel
16
25
 
17
- DECORATED_FUNC = TypeVar("DECORATED_FUNC", bound=Callable[..., Awaitable[Any]])
26
+ from jararaca.reflect.decorators import StackableDecorator
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ FUNC_T = Callable[..., Awaitable[Any]]
31
+ DECORATED_FUNC = TypeVar("DECORATED_FUNC", bound=FUNC_T)
32
+ DECORATED_CLASS = TypeVar("DECORATED_CLASS", bound=Any)
33
+
18
34
 
35
+ class TimeoutException(Exception):
36
+ """Exception raised when a request times out"""
19
37
 
20
- class HttpMapping:
21
38
 
22
- HTTP_MAPPING_ATTR = "__rest_http_client_mapping__"
39
+ class HttpMapping(StackableDecorator):
23
40
 
24
41
  def __init__(self, method: str, path: str, success_statuses: Iterable[int] = [200]):
25
42
  self.method = method
26
43
  self.path = path
27
44
  self.success_statuses = success_statuses
28
45
 
29
- def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
30
- HttpMapping.register(func, self)
31
- return func
32
-
33
- @staticmethod
34
- def register(funf: DECORATED_FUNC, instance: "HttpMapping") -> None:
35
- setattr(funf, HttpMapping.HTTP_MAPPING_ATTR, instance)
36
-
37
- @staticmethod
38
- def get(funf: DECORATED_FUNC) -> "HttpMapping | None":
39
- if hasattr(funf, HttpMapping.HTTP_MAPPING_ATTR):
40
- return cast(HttpMapping, getattr(funf, HttpMapping.HTTP_MAPPING_ATTR))
41
-
42
- return None
46
+ @classmethod
47
+ def decorator_key(cls) -> Any:
48
+ return HttpMapping
43
49
 
44
50
 
45
51
  class Post(HttpMapping):
@@ -72,37 +78,19 @@ class Delete(HttpMapping):
72
78
  super().__init__("DELETE", path)
73
79
 
74
80
 
75
- class RequestAttribute:
76
-
77
- REQUEST_ATTRIBUTE_ATTRS = "__request_attributes__"
78
-
79
- @staticmethod
80
- def register(cls: DECORATED_FUNC, instance: "RequestAttribute") -> None:
81
-
82
- if not hasattr(cls, RequestAttribute.REQUEST_ATTRIBUTE_ATTRS):
83
- setattr(cls, RequestAttribute.REQUEST_ATTRIBUTE_ATTRS, [])
84
-
85
- getattr(cls, RequestAttribute.REQUEST_ATTRIBUTE_ATTRS).append(instance)
86
-
87
- @staticmethod
88
- def get(cls: DECORATED_FUNC) -> "list[RequestAttribute]":
89
- if hasattr(cls, RequestAttribute.REQUEST_ATTRIBUTE_ATTRS):
90
- return cast(
91
- list[RequestAttribute],
92
- getattr(cls, RequestAttribute.REQUEST_ATTRIBUTE_ATTRS),
93
- )
94
-
95
- return []
81
+ class RequestAttribute(StackableDecorator):
96
82
 
97
83
  def __init__(
98
- self, attribute_type: Literal["query", "header", "body", "param"], name: str
84
+ self,
85
+ attribute_type: Literal["query", "header", "body", "param", "form", "file"],
86
+ name: str,
99
87
  ):
100
88
  self.attribute_type = attribute_type
101
89
  self.name = name
102
90
 
103
- def __call__(self, cls: DECORATED_FUNC) -> DECORATED_FUNC:
104
- RequestAttribute.register(cls, self)
105
- return cls
91
+ @classmethod
92
+ def decorator_key(cls) -> Any:
93
+ return RequestAttribute
106
94
 
107
95
 
108
96
  class Query(RequestAttribute):
@@ -129,39 +117,119 @@ class PathParam(RequestAttribute):
129
117
  super().__init__("param", name)
130
118
 
131
119
 
132
- DECORATED_CLASS = TypeVar("DECORATED_CLASS", bound=Any)
120
+ class FormData(RequestAttribute):
121
+ """Decorator for form data parameters"""
133
122
 
123
+ def __init__(self, name: str):
124
+ super().__init__("form", name)
134
125
 
135
- class RestClient:
136
126
 
137
- REST_CLIENT_ATTR = "__rest_client__"
127
+ class File(RequestAttribute):
128
+ """Decorator for file upload parameters"""
138
129
 
139
- def __init__(self, base_path: str) -> None:
140
- self.base_path = base_path
130
+ def __init__(self, name: str):
131
+ super().__init__("file", name)
132
+
133
+
134
+ class Timeout:
135
+ """Decorator for setting request timeout"""
136
+
137
+ TIMEOUT_ATTR = "__request_timeout__"
138
+
139
+ def __init__(self, seconds: float):
140
+ self.seconds = seconds
141
+
142
+ def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
143
+ setattr(func, self.TIMEOUT_ATTR, self)
144
+ return func
145
+
146
+ @staticmethod
147
+ def get(func: FUNC_T) -> Optional["Timeout"]:
148
+ return getattr(func, Timeout.TIMEOUT_ATTR, None)
149
+
150
+
151
+ class RPCRetryPolicy:
152
+ """Configuration for retry behavior"""
153
+
154
+ def __init__(
155
+ self,
156
+ max_attempts: int = 3,
157
+ backoff_factor: float = 1.0,
158
+ retry_on_status_codes: Optional[list[int]] = None,
159
+ ):
160
+ self.max_attempts = max_attempts
161
+ self.backoff_factor = backoff_factor
162
+ self.retry_on_status_codes = retry_on_status_codes or [500, 502, 503, 504]
163
+
164
+
165
+ class Retry:
166
+ """Decorator for retry configuration"""
167
+
168
+ RETRY_ATTR = "__request_retry__"
169
+
170
+ def __init__(self, config: RPCRetryPolicy):
171
+ self.config = config
172
+
173
+ def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
174
+ setattr(func, self.RETRY_ATTR, self)
175
+ return func
141
176
 
142
177
  @staticmethod
143
- def register(cls: type, instance: "RestClient") -> None:
144
- setattr(cls, RestClient.REST_CLIENT_ATTR, instance)
178
+ def get(func: FUNC_T) -> Optional["Retry"]:
179
+ return getattr(func, Retry.RETRY_ATTR, None)
180
+
181
+
182
+ class ContentType:
183
+ """Decorator for specifying content type"""
184
+
185
+ CONTENT_TYPE_ATTR = "__content_type__"
186
+
187
+ def __init__(self, content_type: str):
188
+ self.content_type = content_type
189
+
190
+ def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
191
+ setattr(func, self.CONTENT_TYPE_ATTR, self)
192
+ return func
145
193
 
146
194
  @staticmethod
147
- def get(cls: type) -> "RestClient | None":
148
- if hasattr(cls, RestClient.REST_CLIENT_ATTR):
149
- return cast(RestClient, getattr(cls, RestClient.REST_CLIENT_ATTR))
195
+ def get(func: FUNC_T) -> Optional["ContentType"]:
196
+ return getattr(func, ContentType.CONTENT_TYPE_ATTR, None)
197
+
198
+
199
+ class ResponseMiddleware(Protocol):
200
+ """Protocol for response middleware"""
201
+
202
+ def on_response(
203
+ self, request: "HttpRPCRequest", response: "HttpRPCResponse"
204
+ ) -> "HttpRPCResponse": ...
205
+
150
206
 
151
- return None
207
+ class RequestHook(Protocol):
208
+ """Protocol for request hooks"""
152
209
 
153
- def __call__(self, cls: Type[DECORATED_CLASS]) -> Type[DECORATED_CLASS]:
210
+ def before_request(self, request: "HttpRPCRequest") -> "HttpRPCRequest": ...
154
211
 
155
- RestClient.register(cls, self)
156
212
 
157
- return cls
213
+ class ResponseHook(Protocol):
214
+ """Protocol for response hooks"""
215
+
216
+ def after_response(
217
+ self, request: "HttpRPCRequest", response: "HttpRPCResponse"
218
+ ) -> "HttpRPCResponse": ...
219
+
220
+
221
+ class RestClient(StackableDecorator):
222
+
223
+ def __init__(self, base_path: str) -> None:
224
+ self.base_path = base_path
158
225
 
159
226
 
160
227
  @dataclass
161
228
  class HttpRPCResponse:
162
-
163
229
  status_code: int
164
230
  data: bytes
231
+ headers: Optional[Dict[str, str]] = None
232
+ elapsed_time: Optional[float] = None
165
233
 
166
234
 
167
235
  @dataclass
@@ -171,6 +239,9 @@ class HttpRPCRequest:
171
239
  headers: list[tuple[str, str]]
172
240
  query_params: dict[str, str]
173
241
  body: bytes | None
242
+ timeout: Optional[float] = None
243
+ form_data: Optional[Dict[str, Any]] = None
244
+ files: Optional[Dict[str, Any]] = None
174
245
 
175
246
 
176
247
  class RPCRequestNetworkError(Exception):
@@ -197,82 +268,110 @@ class HandleHttpErrorCallback(Protocol):
197
268
  def __call__(self, request: HttpRPCRequest, response: HttpRPCResponse) -> Any: ...
198
269
 
199
270
 
200
- class GlobalHttpErrorHandler:
271
+ class GlobalHttpErrorHandler(StackableDecorator):
201
272
 
202
- HTTP_ERROR_ATTR = "__global_http_error__"
273
+ def __init__(self, status_code: int, callback: HandleHttpErrorCallback):
274
+ self.status_code = status_code
275
+ self.callback = callback
276
+
277
+
278
+ class RouteHttpErrorHandler(StackableDecorator):
203
279
 
204
280
  def __init__(self, status_code: int, callback: HandleHttpErrorCallback):
205
281
  self.status_code = status_code
206
282
  self.callback = callback
207
283
 
208
- def __call__(self, cls: Type[DECORATED_CLASS]) -> Type[DECORATED_CLASS]:
209
- GlobalHttpErrorHandler.register(cls, self)
210
- return cls
211
284
 
212
- @staticmethod
213
- def register(
214
- cls: Type[DECORATED_CLASS], instance: "GlobalHttpErrorHandler"
215
- ) -> None:
216
- if not hasattr(cls, GlobalHttpErrorHandler.HTTP_ERROR_ATTR):
217
- setattr(cls, GlobalHttpErrorHandler.HTTP_ERROR_ATTR, [])
285
+ class HttpRPCAsyncBackend(Protocol):
218
286
 
219
- getattr(cls, GlobalHttpErrorHandler.HTTP_ERROR_ATTR).append(instance)
287
+ async def request(
288
+ self,
289
+ request: HttpRPCRequest,
290
+ ) -> HttpRPCResponse: ...
220
291
 
221
- @staticmethod
222
- def get(cls: Type[DECORATED_CLASS]) -> "list[GlobalHttpErrorHandler]":
223
- if hasattr(cls, GlobalHttpErrorHandler.HTTP_ERROR_ATTR):
224
- return cast(
225
- list[GlobalHttpErrorHandler],
226
- getattr(cls, GlobalHttpErrorHandler.HTTP_ERROR_ATTR),
227
- )
228
292
 
229
- return []
293
+ T = TypeVar("T")
230
294
 
231
295
 
232
- class RouteHttpErrorHandler:
296
+ class RequestMiddleware(Protocol):
233
297
 
234
- ATTR = "__route_http_errors__"
298
+ def on_request(self, request: HttpRPCRequest) -> HttpRPCRequest: ...
235
299
 
236
- def __init__(self, status_code: int, callback: HandleHttpErrorCallback):
237
- self.status_code = status_code
238
- self.callback = callback
239
300
 
240
- def __call__(self, cls: DECORATED_FUNC) -> DECORATED_FUNC:
241
- RouteHttpErrorHandler.register(cls, self)
242
- return cls
301
+ class AuthenticationMiddleware(RequestMiddleware):
302
+ """Base class for authentication middleware"""
243
303
 
244
- @staticmethod
245
- def register(cls: DECORATED_FUNC, instance: "RouteHttpErrorHandler") -> None:
246
- if not hasattr(cls, RouteHttpErrorHandler.ATTR):
247
- setattr(cls, RouteHttpErrorHandler.ATTR, [])
304
+ def on_request(self, request: HttpRPCRequest) -> HttpRPCRequest:
305
+ return self.add_auth(request)
248
306
 
249
- getattr(cls, RouteHttpErrorHandler.ATTR).append(instance)
307
+ def add_auth(self, request: HttpRPCRequest) -> HttpRPCRequest:
308
+ raise NotImplementedError
250
309
 
251
- @staticmethod
252
- def get(cls: DECORATED_FUNC) -> "list[RouteHttpErrorHandler]":
253
- if hasattr(cls, RouteHttpErrorHandler.ATTR):
254
- return cast(
255
- list[RouteHttpErrorHandler],
256
- getattr(cls, RouteHttpErrorHandler.ATTR),
257
- )
258
310
 
259
- return []
311
+ class BearerTokenAuth(AuthenticationMiddleware):
312
+ """Bearer token authentication middleware"""
260
313
 
314
+ def __init__(self, token: str):
315
+ self.token = token
261
316
 
262
- class HttpRPCAsyncBackend(Protocol):
317
+ def add_auth(self, request: HttpRPCRequest) -> HttpRPCRequest:
318
+ request.headers.append(("Authorization", f"Bearer {self.token}"))
319
+ return request
263
320
 
264
- async def request(
265
- self,
266
- request: HttpRPCRequest,
267
- ) -> HttpRPCResponse: ...
268
321
 
322
+ class BasicAuth(AuthenticationMiddleware):
323
+ """Basic authentication middleware"""
269
324
 
270
- T = TypeVar("T")
325
+ def __init__(self, username: str, password: str):
326
+ import base64
271
327
 
328
+ credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
329
+ self.credentials = credentials
272
330
 
273
- class RequestMiddleware(Protocol):
331
+ def add_auth(self, request: HttpRPCRequest) -> HttpRPCRequest:
332
+ request.headers.append(("Authorization", f"Basic {self.credentials}"))
333
+ return request
274
334
 
275
- def on_request(self, request: HttpRPCRequest) -> HttpRPCRequest: ...
335
+
336
+ class ApiKeyAuth(AuthenticationMiddleware):
337
+ """API key authentication middleware"""
338
+
339
+ def __init__(self, api_key: str, header_name: str = "X-API-Key"):
340
+ self.api_key = api_key
341
+ self.header_name = header_name
342
+
343
+ def add_auth(self, request: HttpRPCRequest) -> HttpRPCRequest:
344
+ request.headers.append((self.header_name, self.api_key))
345
+ return request
346
+
347
+
348
+ class CacheMiddleware(RequestMiddleware):
349
+ """Simple in-memory cache middleware"""
350
+
351
+ def __init__(self, ttl_seconds: int = 300):
352
+ self.cache: Dict[str, tuple[Any, float]] = {}
353
+ self.ttl_seconds = ttl_seconds
354
+
355
+ def _cache_key(self, request: HttpRPCRequest) -> str:
356
+ """Generate cache key from request"""
357
+ key_data = {
358
+ "method": request.method,
359
+ "url": request.url,
360
+ "query_params": request.query_params,
361
+ "headers": sorted(request.headers),
362
+ }
363
+ return str(hash(json.dumps(key_data, sort_keys=True)))
364
+
365
+ def on_request(self, request: HttpRPCRequest) -> HttpRPCRequest:
366
+ # Only cache GET requests
367
+ if request.method == "GET":
368
+ cache_key = self._cache_key(request)
369
+ if cache_key in self.cache:
370
+ cached_response, timestamp = self.cache[cache_key]
371
+ if time.time() - timestamp < self.ttl_seconds:
372
+ # Return cached response (this needs to be handled in the client builder)
373
+ setattr(request, "_cached_response", cached_response)
374
+ return request
276
375
 
277
376
 
278
377
  class HttpRpcClientBuilder:
@@ -281,12 +380,79 @@ class HttpRpcClientBuilder:
281
380
  self,
282
381
  backend: HttpRPCAsyncBackend,
283
382
  middlewares: list[RequestMiddleware] = [],
383
+ response_middlewares: list[ResponseMiddleware] = [],
384
+ request_hooks: list[RequestHook] = [],
385
+ response_hooks: list[ResponseHook] = [],
284
386
  ):
285
387
  self._backend = backend
286
388
  self._middlewares = middlewares
389
+ self._response_middlewares = response_middlewares
390
+ self._request_hooks = request_hooks
391
+ self._response_hooks = response_hooks
392
+
393
+ async def _execute_with_retry(
394
+ self, request: HttpRPCRequest, retry_config: Optional[RPCRetryPolicy]
395
+ ) -> HttpRPCResponse:
396
+ """Execute request with retry logic"""
397
+ if not retry_config:
398
+ logger.debug(
399
+ "Executing request without retry config: %s %s",
400
+ request.method,
401
+ request.url,
402
+ )
403
+ return await self._backend.request(request)
404
+
405
+ logger.debug(
406
+ "Executing request with retry config: %s %s (max_attempts=%s)",
407
+ request.method,
408
+ request.url,
409
+ retry_config.max_attempts,
410
+ )
411
+ last_exception = None
412
+ for attempt in range(retry_config.max_attempts):
413
+ try:
414
+ response = await self._backend.request(request)
415
+
416
+ # Check if we should retry based on status code
417
+ if response.status_code in retry_config.retry_on_status_codes:
418
+ logger.warning(
419
+ "Request failed with status %s, retrying (attempt %s/%s)",
420
+ response.status_code,
421
+ attempt + 1,
422
+ retry_config.max_attempts,
423
+ )
424
+ if attempt < retry_config.max_attempts - 1:
425
+ wait_time = retry_config.backoff_factor * (2**attempt)
426
+ await asyncio.sleep(wait_time)
427
+ continue
428
+
429
+ return response
430
+
431
+ except Exception as e:
432
+ last_exception = e
433
+ logger.warning(
434
+ "Request failed with exception: %s, retrying (attempt %s/%s)",
435
+ e,
436
+ attempt + 1,
437
+ retry_config.max_attempts,
438
+ )
439
+ if attempt < retry_config.max_attempts - 1:
440
+ wait_time = retry_config.backoff_factor * (2**attempt)
441
+ await asyncio.sleep(wait_time)
442
+ continue
443
+ else:
444
+ logger.error(
445
+ "Request failed after %s attempts: %s",
446
+ retry_config.max_attempts,
447
+ e,
448
+ )
449
+ raise
450
+
451
+ # This should never be reached, but just in case
452
+ raise last_exception or Exception("Retry failed")
287
453
 
288
454
  def build(self, cls: type[T]) -> T:
289
- rest_client = RestClient.get(cls)
455
+ rest_client = RestClient.get_last(cls)
290
456
 
291
457
  global_error_handlers = GlobalHttpErrorHandler.get(cls)
292
458
 
@@ -304,6 +470,12 @@ class HttpRpcClientBuilder:
304
470
  call_parameters = [*call_signature.parameters.keys()][1:]
305
471
 
306
472
  async def rpc_method(*args: Any, **kwargs: Any) -> Any:
473
+ logger.debug(
474
+ "Calling RPC method %s with args=%s kwargs=%s",
475
+ method_call.__name__,
476
+ args,
477
+ kwargs,
478
+ )
307
479
 
308
480
  args_as_kwargs = dict(zip(call_parameters, args))
309
481
 
@@ -314,10 +486,17 @@ class HttpRpcClientBuilder:
314
486
  headers: list[tuple[str, str]] = []
315
487
  query_params = {}
316
488
  body: Any = None
489
+ form_data: Dict[str, Any] = {}
490
+ files: Dict[str, Any] = {}
317
491
  compiled_path = (
318
492
  rest_client.base_path.rstrip("/") + "/" + mapping.path.lstrip("/")
319
493
  )
320
494
 
495
+ # Get decorators for this method
496
+ timeout_config = Timeout.get(method_call)
497
+ retry_config = Retry.get(method_call)
498
+ content_type_config = ContentType.get(method_call)
499
+
321
500
  for attr in request_attributes:
322
501
  if attr.attribute_type == "header":
323
502
  headers.append((attr.name, compiled_kwargs[attr.name]))
@@ -329,17 +508,33 @@ class HttpRpcClientBuilder:
329
508
  compiled_path = compiled_path.replace(
330
509
  f":{attr.name}", str(compiled_kwargs[attr.name])
331
510
  )
511
+ elif attr.attribute_type == "form":
512
+ form_data[attr.name] = compiled_kwargs[attr.name]
513
+ elif attr.attribute_type == "file":
514
+ files[attr.name] = compiled_kwargs[attr.name]
332
515
 
333
516
  body_content: bytes | None = None
334
517
 
518
+ # Handle different content types
335
519
  if body is not None:
336
520
  if isinstance(body, BaseModel):
337
521
  body_content = body.model_dump_json().encode()
338
- headers.append(("Content-Type", "application/json"))
522
+ if not content_type_config:
523
+ headers.append(("Content-Type", "application/json"))
339
524
  elif isinstance(body, bytes):
340
525
  body_content = body
526
+ elif isinstance(body, str):
527
+ body_content = body.encode()
528
+ elif isinstance(body, dict):
529
+ body_content = json.dumps(body).encode()
530
+ if not content_type_config:
531
+ headers.append(("Content-Type", "application/json"))
341
532
  else:
342
- raise ValueError("Invalid body type")
533
+ raise ValueError(f"Invalid body type: {type(body)}")
534
+
535
+ # Apply custom content type if specified
536
+ if content_type_config:
537
+ headers.append(("Content-Type", content_type_config.content_type))
343
538
 
344
539
  request = HttpRPCRequest(
345
540
  url=compiled_path,
@@ -347,20 +542,75 @@ class HttpRpcClientBuilder:
347
542
  headers=headers,
348
543
  query_params=query_params,
349
544
  body=body_content,
545
+ timeout=timeout_config.seconds if timeout_config else None,
546
+ form_data=form_data if form_data else None,
547
+ files=files if files else None,
350
548
  )
351
549
 
550
+ logger.debug(
551
+ "Prepared request: %s %s\nHeaders: %s\nQuery Params: %s\nBody: %s",
552
+ request.method,
553
+ request.url,
554
+ request.headers,
555
+ request.query_params,
556
+ request.body,
557
+ )
558
+
559
+ # Apply request hooks
560
+ for hook in self._request_hooks:
561
+ request = hook.before_request(request)
562
+
352
563
  for middleware in self._middlewares:
353
564
  request = middleware.on_request(request)
354
565
 
355
- response = await self._backend.request(request)
566
+ # Check for cached response
567
+ if hasattr(request, "_cached_response"):
568
+ logger.debug("Using cached response")
569
+ response = getattr(request, "_cached_response")
570
+ else:
571
+ # Execute request with retry if configured
572
+ logger.debug("Executing request...")
573
+ response = await self._execute_with_retry(
574
+ request, retry_config.config if retry_config else None
575
+ )
576
+ logger.debug("Received response: status=%s", response.status_code)
577
+
578
+ # Apply response middleware
579
+ for response_middleware in self._response_middlewares:
580
+ response = response_middleware.on_response(request, response)
581
+
582
+ # Apply response hooks
583
+ for response_hook in self._response_hooks:
584
+ response = response_hook.after_response(request, response)
585
+
586
+ # Cache response if using cache middleware and it's a GET request
587
+ if request.method == "GET":
588
+ for middleware in self._middlewares:
589
+ if isinstance(middleware, CacheMiddleware):
590
+ cache_key = middleware._cache_key(request)
591
+ middleware.cache[cache_key] = (response, time.time())
356
592
 
357
593
  return_type = inspect.signature(method_call).return_annotation
358
594
 
359
595
  if response.status_code not in mapping.success_statuses:
596
+ logger.warning(
597
+ "Response status %s not in success statuses %s",
598
+ response.status_code,
599
+ mapping.success_statuses,
600
+ )
360
601
  for error_handler in route_error_handlers + global_error_handlers:
361
602
  if error_handler.status_code == response.status_code:
603
+ logger.debug(
604
+ "Handling error with handler for status %s",
605
+ response.status_code,
606
+ )
362
607
  return error_handler.callback(request, response)
363
608
 
609
+ logger.error(
610
+ "Unhandled RPC error: %s %s",
611
+ response.status_code,
612
+ response.data,
613
+ )
364
614
  raise RPCUnhandleError(request, response, None)
365
615
 
366
616
  if return_type is not inspect.Signature.empty:
@@ -379,9 +629,10 @@ class HttpRpcClientBuilder:
379
629
 
380
630
  dummy = Dummy()
381
631
 
382
- for attr_name in dir(cls):
383
- method_call = getattr(cls, attr_name)
384
- if (mapping := HttpMapping.get(method_call)) is not None:
632
+ for attr_name, method_call in inspect.getmembers(
633
+ cls, predicate=inspect.isfunction
634
+ ):
635
+ if (mapping := HttpMapping.get_last(method_call)) is not None:
385
636
  route_error_handlers = RouteHttpErrorHandler.get(method_call)
386
637
  setattr(
387
638
  dummy,
@@ -407,14 +658,28 @@ __all__ = [
407
658
  "Header",
408
659
  "Body",
409
660
  "PathParam",
661
+ "FormData",
662
+ "File",
663
+ "Timeout",
664
+ "RPCRetryPolicy",
665
+ "Retry",
666
+ "ContentType",
410
667
  "RestClient",
411
668
  "HttpRPCAsyncBackend",
412
669
  "HttpRPCRequest",
413
670
  "HttpRPCResponse",
414
671
  "RPCRequestNetworkError",
672
+ "RPCUnhandleError",
415
673
  "HttpRpcClientBuilder",
416
674
  "RequestMiddleware",
417
- "TracedRequestMiddleware",
675
+ "ResponseMiddleware",
676
+ "RequestHook",
677
+ "ResponseHook",
678
+ "AuthenticationMiddleware",
679
+ "BearerTokenAuth",
680
+ "BasicAuth",
681
+ "ApiKeyAuth",
682
+ "CacheMiddleware",
418
683
  "GlobalHttpErrorHandler",
419
684
  "RouteHttpErrorHandler",
420
685
  "HandleHttpErrorCallback",
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later