railhook 2.12.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.
railhook/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """Official Python SDK for Railhook."""
2
+
3
+ from .client import Railhook
4
+ from .errors import (
5
+ RailhookError,
6
+ AuthenticationError,
7
+ RateLimitError,
8
+ ValidationError,
9
+ NotFoundError,
10
+ )
11
+ from .webhooks import (
12
+ verify_signature,
13
+ verify_standard_webhook,
14
+ construct_event,
15
+ generate_signature,
16
+ )
17
+ from .types import (
18
+ Event,
19
+ EventResponse,
20
+ Endpoint,
21
+ EndpointCreateParams,
22
+ EndpointUpdateParams,
23
+ Subscription,
24
+ SubscriptionCreateParams,
25
+ Delivery,
26
+ DeliveryAttempt,
27
+ DeliveryListParams,
28
+ DeliveryStatus,
29
+ PaginatedResponse,
30
+ EndpointTestResult,
31
+ RateLimitInfo,
32
+ WebhookEvent,
33
+ IncomingSource,
34
+ IncomingSourceCreateParams,
35
+ IncomingSourceUpdateParams,
36
+ IncomingDestination,
37
+ IncomingDestinationCreateParams,
38
+ IncomingEvent,
39
+ IncomingEventListParams,
40
+ IncomingForwardAttempt,
41
+ ReplayEventResponse,
42
+ )
43
+
44
+ __version__ = "2.12.0"
45
+
46
+ # Backward-compatible aliases
47
+ WebhookPlatform = Railhook
48
+ WebhookPlatformError = RailhookError
49
+
50
+ __all__ = [
51
+ "Railhook",
52
+ "RailhookError",
53
+ "WebhookPlatform",
54
+ "WebhookPlatformError",
55
+ "AuthenticationError",
56
+ "RateLimitError",
57
+ "ValidationError",
58
+ "NotFoundError",
59
+ "verify_signature",
60
+ "verify_standard_webhook",
61
+ "construct_event",
62
+ "generate_signature",
63
+ "Event",
64
+ "EventResponse",
65
+ "Endpoint",
66
+ "EndpointCreateParams",
67
+ "EndpointUpdateParams",
68
+ "Subscription",
69
+ "SubscriptionCreateParams",
70
+ "Delivery",
71
+ "DeliveryAttempt",
72
+ "DeliveryListParams",
73
+ "DeliveryStatus",
74
+ "PaginatedResponse",
75
+ "EndpointTestResult",
76
+ "RateLimitInfo",
77
+ "WebhookEvent",
78
+ "IncomingSource",
79
+ "IncomingSourceCreateParams",
80
+ "IncomingSourceUpdateParams",
81
+ "IncomingDestination",
82
+ "IncomingDestinationCreateParams",
83
+ "IncomingEvent",
84
+ "IncomingEventListParams",
85
+ "IncomingForwardAttempt",
86
+ "ReplayEventResponse",
87
+ ]
railhook/client.py ADDED
@@ -0,0 +1,548 @@
1
+ """Railhook API client."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+ import requests
5
+
6
+ from .types import (
7
+ Event,
8
+ EventResponse,
9
+ Endpoint,
10
+ EndpointCreateParams,
11
+ EndpointUpdateParams,
12
+ Subscription,
13
+ SubscriptionCreateParams,
14
+ Delivery,
15
+ DeliveryAttempt,
16
+ DeliveryListParams,
17
+ PaginatedResponse,
18
+ EndpointTestResult,
19
+ RateLimitInfo,
20
+ IncomingSource,
21
+ IncomingSourceCreateParams,
22
+ IncomingSourceUpdateParams,
23
+ IncomingDestination,
24
+ IncomingDestinationCreateParams,
25
+ IncomingEvent,
26
+ IncomingEventListParams,
27
+ IncomingForwardAttempt,
28
+ ReplayEventResponse,
29
+ )
30
+ from .errors import (
31
+ RailhookError,
32
+ AuthenticationError,
33
+ RateLimitError,
34
+ ValidationError,
35
+ NotFoundError,
36
+ )
37
+
38
+ DEFAULT_BASE_URL = "http://localhost:8080"
39
+ DEFAULT_TIMEOUT = 30
40
+ SDK_VERSION = "2.12.0"
41
+
42
+
43
+ class Railhook:
44
+ """Railhook API client."""
45
+
46
+ def __init__(
47
+ self,
48
+ api_key: str,
49
+ base_url: str = DEFAULT_BASE_URL,
50
+ timeout: int = DEFAULT_TIMEOUT,
51
+ ) -> None:
52
+ if not api_key:
53
+ raise ValueError("API key is required")
54
+
55
+ self.api_key = api_key
56
+ self.base_url = base_url.rstrip("/")
57
+ self.timeout = timeout
58
+
59
+ self.events = Events(self)
60
+ self.endpoints = Endpoints(self)
61
+ self.subscriptions = Subscriptions(self)
62
+ self.deliveries = Deliveries(self)
63
+ self.incoming_sources = IncomingSources(self)
64
+ self.incoming_events = IncomingEventsApi(self)
65
+
66
+ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
67
+ """Generic GET request. Use for endpoints not yet covered by the SDK."""
68
+ return self._request("GET", path, params=params)
69
+
70
+ def post(
71
+ self,
72
+ path: str,
73
+ body: Optional[Dict[str, Any]] = None,
74
+ idempotency_key: Optional[str] = None,
75
+ ) -> Any:
76
+ """Generic POST request. Use for endpoints not yet covered by the SDK."""
77
+ return self._request("POST", path, body=body, idempotency_key=idempotency_key)
78
+
79
+ def put(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
80
+ """Generic PUT request. Use for endpoints not yet covered by the SDK."""
81
+ return self._request("PUT", path, body=body)
82
+
83
+ def patch(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
84
+ """Generic PATCH request. Use for endpoints not yet covered by the SDK."""
85
+ return self._request("PATCH", path, body=body)
86
+
87
+ def delete(self, path: str) -> Any:
88
+ """Generic DELETE request. Use for endpoints not yet covered by the SDK."""
89
+ return self._request("DELETE", path)
90
+
91
+ def _request(
92
+ self,
93
+ method: str,
94
+ path: str,
95
+ body: Optional[Dict[str, Any]] = None,
96
+ params: Optional[Dict[str, Any]] = None,
97
+ idempotency_key: Optional[str] = None,
98
+ ) -> Any:
99
+ url = f"{self.base_url}{path}"
100
+
101
+ headers = {
102
+ "X-API-Key": self.api_key,
103
+ "Content-Type": "application/json",
104
+ "User-Agent": f"railhook-python/{SDK_VERSION}",
105
+ }
106
+
107
+ if idempotency_key:
108
+ headers["Idempotency-Key"] = idempotency_key
109
+
110
+ try:
111
+ response = requests.request(
112
+ method=method,
113
+ url=url,
114
+ json=body,
115
+ params=params,
116
+ headers=headers,
117
+ timeout=self.timeout,
118
+ )
119
+ except requests.Timeout:
120
+ raise RailhookError("Request timeout", 0, "timeout")
121
+ except requests.RequestException as e:
122
+ raise RailhookError(str(e), 0, "network_error")
123
+
124
+ rate_limit_info = self._extract_rate_limit_info(response.headers)
125
+
126
+ if response.status_code == 204:
127
+ return None
128
+
129
+ try:
130
+ data = response.json() if response.text else {}
131
+ except ValueError:
132
+ data = {}
133
+
134
+ if response.status_code >= 400:
135
+ raise self._handle_error(response.status_code, data, rate_limit_info)
136
+
137
+ return data
138
+
139
+ def _extract_rate_limit_info(
140
+ self, headers: requests.structures.CaseInsensitiveDict
141
+ ) -> Optional[RateLimitInfo]:
142
+ limit = headers.get("X-RateLimit-Limit")
143
+ remaining = headers.get("X-RateLimit-Remaining")
144
+ reset = headers.get("X-RateLimit-Reset")
145
+
146
+ if limit and remaining and reset:
147
+ return RateLimitInfo(
148
+ limit=int(limit),
149
+ remaining=int(remaining),
150
+ reset=int(reset),
151
+ )
152
+ return None
153
+
154
+ def _handle_error(
155
+ self,
156
+ status: int,
157
+ body: Dict[str, Any],
158
+ rate_limit_info: Optional[RateLimitInfo],
159
+ ) -> RailhookError:
160
+ message = body.get("message", "Unknown error")
161
+
162
+ if status == 401:
163
+ return AuthenticationError(message)
164
+ elif status == 404:
165
+ return NotFoundError(message)
166
+ elif status == 429:
167
+ import time
168
+ # `reset` is a Unix timestamp in seconds (see RateLimitInfo), so the
169
+ # fallback has to be in seconds too.
170
+ info = rate_limit_info or RateLimitInfo(
171
+ limit=0, remaining=0, reset=int(time.time()) + 60
172
+ )
173
+ return RateLimitError(message, info)
174
+ elif status == 400:
175
+ return ValidationError(message, body.get("fieldErrors", {}))
176
+ else:
177
+ return RailhookError(message, status, body.get("error"))
178
+
179
+
180
+ class Events:
181
+ """Events API."""
182
+
183
+ def __init__(self, client: Railhook) -> None:
184
+ self._client = client
185
+
186
+ def send(
187
+ self, event: Event, idempotency_key: Optional[str] = None
188
+ ) -> EventResponse:
189
+ """Send an event to be delivered to subscribed endpoints."""
190
+ data = self._client._request(
191
+ "POST",
192
+ "/api/v1/events",
193
+ body={"type": event.type, "data": event.data},
194
+ idempotency_key=idempotency_key,
195
+ )
196
+ return EventResponse.from_dict(data)
197
+
198
+
199
+ class Endpoints:
200
+ """Endpoints API."""
201
+
202
+ def __init__(self, client: Railhook) -> None:
203
+ self._client = client
204
+
205
+ def create(self, project_id: str, params: EndpointCreateParams) -> Endpoint:
206
+ """Create a new endpoint."""
207
+ data = self._client._request(
208
+ "POST",
209
+ f"/api/v1/projects/{project_id}/endpoints",
210
+ body=params.to_dict(),
211
+ )
212
+ return Endpoint.from_dict(data)
213
+
214
+ def get(self, project_id: str, endpoint_id: str) -> Endpoint:
215
+ """Get endpoint by ID."""
216
+ data = self._client._request(
217
+ "GET",
218
+ f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}",
219
+ )
220
+ return Endpoint.from_dict(data)
221
+
222
+ def list(
223
+ self, project_id: str, page: int = 0, size: int = 20
224
+ ) -> PaginatedResponse:
225
+ """List a project's endpoints.
226
+
227
+ The API returns a Spring page envelope here, not a bare array — the
228
+ endpoints themselves are in ``.content`` (iterating the page yields
229
+ them directly).
230
+ """
231
+ data = self._client._request(
232
+ "GET",
233
+ f"/api/v1/projects/{project_id}/endpoints",
234
+ params={"page": page, "size": size},
235
+ )
236
+ return PaginatedResponse.from_dict(data, Endpoint)
237
+
238
+ def update(
239
+ self, project_id: str, endpoint_id: str, params: EndpointUpdateParams
240
+ ) -> Endpoint:
241
+ """Update endpoint."""
242
+ data = self._client._request(
243
+ "PUT",
244
+ f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}",
245
+ body=params.to_dict(),
246
+ )
247
+ return Endpoint.from_dict(data)
248
+
249
+ def delete(self, project_id: str, endpoint_id: str) -> None:
250
+ """Delete endpoint."""
251
+ self._client._request(
252
+ "DELETE",
253
+ f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}",
254
+ )
255
+
256
+ def rotate_secret(self, project_id: str, endpoint_id: str) -> Endpoint:
257
+ """Rotate endpoint webhook secret."""
258
+ data = self._client._request(
259
+ "POST",
260
+ f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}/rotate-secret",
261
+ )
262
+ return Endpoint.from_dict(data)
263
+
264
+ def test(self, project_id: str, endpoint_id: str) -> EndpointTestResult:
265
+ """Test endpoint connectivity."""
266
+ data = self._client._request(
267
+ "POST",
268
+ f"/api/v1/projects/{project_id}/endpoints/{endpoint_id}/test",
269
+ )
270
+ return EndpointTestResult.from_dict(data)
271
+
272
+
273
+ class Subscriptions:
274
+ """Subscriptions API."""
275
+
276
+ def __init__(self, client: Railhook) -> None:
277
+ self._client = client
278
+
279
+ def create(
280
+ self, project_id: str, params: SubscriptionCreateParams
281
+ ) -> Subscription:
282
+ """Create a new subscription."""
283
+ data = self._client._request(
284
+ "POST",
285
+ f"/api/v1/projects/{project_id}/subscriptions",
286
+ body=params.to_dict(),
287
+ )
288
+ return Subscription.from_dict(data)
289
+
290
+ def get(self, project_id: str, subscription_id: str) -> Subscription:
291
+ """Get subscription by ID."""
292
+ data = self._client._request(
293
+ "GET",
294
+ f"/api/v1/projects/{project_id}/subscriptions/{subscription_id}",
295
+ )
296
+ return Subscription.from_dict(data)
297
+
298
+ def list(self, project_id: str) -> List[Subscription]:
299
+ """List all subscriptions for a project."""
300
+ data = self._client._request(
301
+ "GET",
302
+ f"/api/v1/projects/{project_id}/subscriptions",
303
+ )
304
+ return [Subscription.from_dict(s) for s in data]
305
+
306
+ def update(
307
+ self,
308
+ project_id: str,
309
+ subscription_id: str,
310
+ event_type: Optional[str] = None,
311
+ enabled: Optional[bool] = None,
312
+ ordering_enabled: Optional[bool] = None,
313
+ max_attempts: Optional[int] = None,
314
+ timeout_seconds: Optional[int] = None,
315
+ retry_delays: Optional[str] = None,
316
+ payload_template: Optional[str] = None,
317
+ custom_headers: Optional[str] = None,
318
+ transformation_id: Optional[str] = None,
319
+ ) -> Subscription:
320
+ """Update subscription."""
321
+ body: Dict[str, Any] = {}
322
+ if event_type is not None:
323
+ body["eventType"] = event_type
324
+ if enabled is not None:
325
+ body["enabled"] = enabled
326
+ if ordering_enabled is not None:
327
+ body["orderingEnabled"] = ordering_enabled
328
+ if max_attempts is not None:
329
+ body["maxAttempts"] = max_attempts
330
+ if timeout_seconds is not None:
331
+ body["timeoutSeconds"] = timeout_seconds
332
+ if retry_delays is not None:
333
+ body["retryDelays"] = retry_delays
334
+ if payload_template is not None:
335
+ body["payloadTemplate"] = payload_template
336
+ if custom_headers is not None:
337
+ body["customHeaders"] = custom_headers
338
+ if transformation_id is not None:
339
+ body["transformationId"] = transformation_id
340
+
341
+ data = self._client._request(
342
+ "PUT",
343
+ f"/api/v1/projects/{project_id}/subscriptions/{subscription_id}",
344
+ body=body,
345
+ )
346
+ return Subscription.from_dict(data)
347
+
348
+ def delete(self, project_id: str, subscription_id: str) -> None:
349
+ """Delete subscription."""
350
+ self._client._request(
351
+ "DELETE",
352
+ f"/api/v1/projects/{project_id}/subscriptions/{subscription_id}",
353
+ )
354
+
355
+
356
+ class Deliveries:
357
+ """Deliveries API."""
358
+
359
+ def __init__(self, client: Railhook) -> None:
360
+ self._client = client
361
+
362
+ def get(self, delivery_id: str) -> Delivery:
363
+ """Get delivery by ID."""
364
+ data = self._client._request("GET", f"/api/v1/deliveries/{delivery_id}")
365
+ return Delivery.from_dict(data)
366
+
367
+ def list(
368
+ self, project_id: str, params: Optional[DeliveryListParams] = None
369
+ ) -> PaginatedResponse:
370
+ """List deliveries for a project with optional filters."""
371
+ query_params = (params or DeliveryListParams()).to_params()
372
+ data = self._client._request(
373
+ "GET",
374
+ f"/api/v1/deliveries/projects/{project_id}",
375
+ params=query_params,
376
+ )
377
+ return PaginatedResponse.from_dict(data)
378
+
379
+ def get_attempts(self, delivery_id: str) -> List[DeliveryAttempt]:
380
+ """Get all delivery attempts."""
381
+ data = self._client._request(
382
+ "GET",
383
+ f"/api/v1/deliveries/{delivery_id}/attempts",
384
+ )
385
+ return [DeliveryAttempt.from_dict(a) for a in data]
386
+
387
+ def replay(self, delivery_id: str) -> None:
388
+ """Replay a failed delivery."""
389
+ self._client._request("POST", f"/api/v1/deliveries/{delivery_id}/replay")
390
+
391
+
392
+ class IncomingSources:
393
+ """Incoming Sources API."""
394
+
395
+ def __init__(self, client: Railhook) -> None:
396
+ self._client = client
397
+
398
+ def create(
399
+ self, project_id: str, params: IncomingSourceCreateParams
400
+ ) -> IncomingSource:
401
+ """Create a new incoming webhook source."""
402
+ data = self._client._request(
403
+ "POST",
404
+ f"/api/v1/projects/{project_id}/incoming-sources",
405
+ body=params.to_dict(),
406
+ )
407
+ return IncomingSource.from_dict(data)
408
+
409
+ def get(self, project_id: str, source_id: str) -> IncomingSource:
410
+ """Get incoming source by ID."""
411
+ data = self._client._request(
412
+ "GET",
413
+ f"/api/v1/projects/{project_id}/incoming-sources/{source_id}",
414
+ )
415
+ return IncomingSource.from_dict(data)
416
+
417
+ def list(self, project_id: str) -> PaginatedResponse:
418
+ """List incoming sources for a project."""
419
+ data = self._client._request(
420
+ "GET",
421
+ f"/api/v1/projects/{project_id}/incoming-sources",
422
+ )
423
+ return PaginatedResponse.from_dict(data, IncomingSource)
424
+
425
+ def update(
426
+ self, project_id: str, source_id: str, params: IncomingSourceUpdateParams
427
+ ) -> IncomingSource:
428
+ """Update incoming source."""
429
+ data = self._client._request(
430
+ "PUT",
431
+ f"/api/v1/projects/{project_id}/incoming-sources/{source_id}",
432
+ body=params.to_dict(),
433
+ )
434
+ return IncomingSource.from_dict(data)
435
+
436
+ def delete(self, project_id: str, source_id: str) -> None:
437
+ """Delete (disable) incoming source."""
438
+ self._client._request(
439
+ "DELETE",
440
+ f"/api/v1/projects/{project_id}/incoming-sources/{source_id}",
441
+ )
442
+
443
+ # ── Destinations ──
444
+
445
+ def create_destination(
446
+ self,
447
+ project_id: str,
448
+ source_id: str,
449
+ params: IncomingDestinationCreateParams,
450
+ ) -> IncomingDestination:
451
+ """Create a forwarding destination for an incoming source."""
452
+ data = self._client._request(
453
+ "POST",
454
+ f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations",
455
+ body=params.to_dict(),
456
+ )
457
+ return IncomingDestination.from_dict(data)
458
+
459
+ def get_destination(
460
+ self, project_id: str, source_id: str, destination_id: str
461
+ ) -> IncomingDestination:
462
+ """Get destination by ID."""
463
+ data = self._client._request(
464
+ "GET",
465
+ f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations/{destination_id}",
466
+ )
467
+ return IncomingDestination.from_dict(data)
468
+
469
+ def list_destinations(
470
+ self, project_id: str, source_id: str
471
+ ) -> PaginatedResponse:
472
+ """List destinations for an incoming source."""
473
+ data = self._client._request(
474
+ "GET",
475
+ f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations",
476
+ )
477
+ return PaginatedResponse.from_dict(data, IncomingDestination)
478
+
479
+ def update_destination(
480
+ self,
481
+ project_id: str,
482
+ source_id: str,
483
+ destination_id: str,
484
+ params: IncomingDestinationCreateParams,
485
+ ) -> IncomingDestination:
486
+ """Update a forwarding destination."""
487
+ data = self._client._request(
488
+ "PUT",
489
+ f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations/{destination_id}",
490
+ body=params.to_dict(),
491
+ )
492
+ return IncomingDestination.from_dict(data)
493
+
494
+ def delete_destination(
495
+ self, project_id: str, source_id: str, destination_id: str
496
+ ) -> None:
497
+ """Delete a forwarding destination."""
498
+ self._client._request(
499
+ "DELETE",
500
+ f"/api/v1/projects/{project_id}/incoming-sources/{source_id}/destinations/{destination_id}",
501
+ )
502
+
503
+
504
+ class IncomingEventsApi:
505
+ """Incoming Events API."""
506
+
507
+ def __init__(self, client: Railhook) -> None:
508
+ self._client = client
509
+
510
+ def list(
511
+ self, project_id: str, params: Optional[IncomingEventListParams] = None
512
+ ) -> PaginatedResponse:
513
+ """List incoming events for a project."""
514
+ query_params = (params or IncomingEventListParams()).to_params()
515
+ data = self._client._request(
516
+ "GET",
517
+ f"/api/v1/projects/{project_id}/incoming-events",
518
+ params=query_params,
519
+ )
520
+ return PaginatedResponse.from_dict(data, IncomingEvent)
521
+
522
+ def get(self, project_id: str, event_id: str) -> IncomingEvent:
523
+ """Get incoming event by ID."""
524
+ data = self._client._request(
525
+ "GET",
526
+ f"/api/v1/projects/{project_id}/incoming-events/{event_id}",
527
+ )
528
+ return IncomingEvent.from_dict(data)
529
+
530
+ def get_attempts(
531
+ self, project_id: str, event_id: str
532
+ ) -> List[IncomingForwardAttempt]:
533
+ """Get forward attempts for an incoming event."""
534
+ data = self._client._request(
535
+ "GET",
536
+ f"/api/v1/projects/{project_id}/incoming-events/{event_id}/attempts",
537
+ )
538
+ if isinstance(data, dict) and "content" in data:
539
+ return [IncomingForwardAttempt.from_dict(a) for a in data["content"]]
540
+ return [IncomingForwardAttempt.from_dict(a) for a in data]
541
+
542
+ def replay(self, project_id: str, event_id: str) -> ReplayEventResponse:
543
+ """Replay an incoming event to all enabled destinations."""
544
+ data = self._client._request(
545
+ "POST",
546
+ f"/api/v1/projects/{project_id}/incoming-events/{event_id}/replay",
547
+ )
548
+ return ReplayEventResponse.from_dict(data)
railhook/errors.py ADDED
@@ -0,0 +1,63 @@
1
+ """Error classes for Railhook SDK."""
2
+
3
+ from typing import Dict, Optional
4
+ from .types import RateLimitInfo
5
+
6
+
7
+ class RailhookError(Exception):
8
+ """Base exception for Railhook SDK."""
9
+
10
+ def __init__(
11
+ self, message: str, status: int = 0, code: Optional[str] = None
12
+ ) -> None:
13
+ super().__init__(message)
14
+ self.message = message
15
+ self.status = status
16
+ self.code = code
17
+
18
+ def __str__(self) -> str:
19
+ return f"{self.__class__.__name__}: {self.message} (status={self.status})"
20
+
21
+
22
+ class AuthenticationError(RailhookError):
23
+ """Raised when API key is invalid or missing."""
24
+
25
+ def __init__(self, message: str = "Invalid API key") -> None:
26
+ super().__init__(message, status=401, code="authentication_error")
27
+
28
+
29
+ class RateLimitError(RailhookError):
30
+ """Raised when rate limit is exceeded."""
31
+
32
+ def __init__(self, message: str, rate_limit_info: RateLimitInfo) -> None:
33
+ super().__init__(message, status=429, code="rate_limit_exceeded")
34
+ self.rate_limit_info = rate_limit_info
35
+
36
+ @property
37
+ def retry_after_ms(self) -> int:
38
+ """Milliseconds to wait before retrying.
39
+
40
+ ``rate_limit_info.reset`` is the raw ``X-RateLimit-Reset`` header, which
41
+ the API sends as a Unix timestamp in **seconds**; subtracting a
42
+ millisecond clock from it directly always yields 0.
43
+ """
44
+ import time
45
+ now_ms = int(time.time() * 1000)
46
+ return max(0, self.rate_limit_info.reset * 1000 - now_ms)
47
+
48
+
49
+ class ValidationError(RailhookError):
50
+ """Raised when request validation fails."""
51
+
52
+ def __init__(
53
+ self, message: str, field_errors: Optional[Dict[str, str]] = None
54
+ ) -> None:
55
+ super().__init__(message, status=400, code="validation_error")
56
+ self.field_errors = field_errors or {}
57
+
58
+
59
+ class NotFoundError(RailhookError):
60
+ """Raised when resource is not found."""
61
+
62
+ def __init__(self, message: str = "Resource not found") -> None:
63
+ super().__init__(message, status=404, code="not_found")