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