hookbase 2.5.0__tar.gz → 2.7.0__tar.gz

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 (80) hide show
  1. {hookbase-2.5.0 → hookbase-2.7.0}/PKG-INFO +2 -2
  2. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/_client.py +8 -2
  3. hookbase-2.7.0/src/hookbase/_version.py +1 -0
  4. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/errors.py +9 -2
  5. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/__init__.py +8 -0
  6. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/cron_jobs.py +29 -7
  7. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/destinations.py +38 -7
  8. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/cron_jobs.py +11 -0
  9. hookbase-2.7.0/tests/resources/test_cron_jobs.py +160 -0
  10. {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_destinations.py +89 -1
  11. {hookbase-2.5.0 → hookbase-2.7.0}/tests/test_errors.py +47 -9
  12. hookbase-2.5.0/src/hookbase/_version.py +0 -1
  13. {hookbase-2.5.0 → hookbase-2.7.0}/.github/workflows/python-publish.yml +0 -0
  14. {hookbase-2.5.0 → hookbase-2.7.0}/.gitignore +0 -0
  15. {hookbase-2.5.0 → hookbase-2.7.0}/LICENSE +0 -0
  16. {hookbase-2.5.0 → hookbase-2.7.0}/README.md +0 -0
  17. {hookbase-2.5.0 → hookbase-2.7.0}/examples/async_usage.py +0 -0
  18. {hookbase-2.5.0 → hookbase-2.7.0}/examples/basic_inbound.py +0 -0
  19. {hookbase-2.5.0 → hookbase-2.7.0}/examples/error_handling.py +0 -0
  20. {hookbase-2.5.0 → hookbase-2.7.0}/examples/send_webhooks.py +0 -0
  21. {hookbase-2.5.0 → hookbase-2.7.0}/examples/verify_webhooks.py +0 -0
  22. {hookbase-2.5.0 → hookbase-2.7.0}/pyproject.toml +0 -0
  23. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/__init__.py +0 -0
  24. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/_constants.py +0 -0
  25. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/_pagination.py +0 -0
  26. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/client.py +0 -0
  27. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/_base.py +0 -0
  28. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/analytics.py +0 -0
  29. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/api_keys.py +0 -0
  30. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/applications.py +0 -0
  31. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/common.py +0 -0
  32. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/deliveries.py +0 -0
  33. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/dlq.py +0 -0
  34. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/endpoints.py +0 -0
  35. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/event_types.py +0 -0
  36. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/events.py +0 -0
  37. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/filters.py +0 -0
  38. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/messages.py +0 -0
  39. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/organizations.py +0 -0
  40. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/portal_tokens.py +0 -0
  41. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/routes.py +0 -0
  42. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/schemas.py +0 -0
  43. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/sources.py +0 -0
  44. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/subscriptions.py +0 -0
  45. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/transforms.py +0 -0
  46. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/tunnels.py +0 -0
  47. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/py.typed +0 -0
  48. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/__init__.py +0 -0
  49. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/_base.py +0 -0
  50. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/analytics.py +0 -0
  51. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/api_keys.py +0 -0
  52. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/applications.py +0 -0
  53. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/deliveries.py +0 -0
  54. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/destinations.py +0 -0
  55. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/dlq.py +0 -0
  56. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/endpoints.py +0 -0
  57. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/event_types.py +0 -0
  58. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/events.py +0 -0
  59. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/filters.py +0 -0
  60. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/message_log.py +0 -0
  61. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/messages.py +0 -0
  62. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/organizations.py +0 -0
  63. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/portal_tokens.py +0 -0
  64. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/routes.py +0 -0
  65. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/schemas.py +0 -0
  66. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/sources.py +0 -0
  67. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/subscriptions.py +0 -0
  68. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/transforms.py +0 -0
  69. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/tunnels.py +0 -0
  70. {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/webhook.py +0 -0
  71. {hookbase-2.5.0 → hookbase-2.7.0}/tests/__init__.py +0 -0
  72. {hookbase-2.5.0 → hookbase-2.7.0}/tests/conftest.py +0 -0
  73. {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/__init__.py +0 -0
  74. {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_applications.py +0 -0
  75. {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_dlq.py +0 -0
  76. {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_messages.py +0 -0
  77. {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_sources.py +0 -0
  78. {hookbase-2.5.0 → hookbase-2.7.0}/tests/test_client.py +0 -0
  79. {hookbase-2.5.0 → hookbase-2.7.0}/tests/test_pagination.py +0 -0
  80. {hookbase-2.5.0 → hookbase-2.7.0}/tests/test_webhook.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hookbase
3
- Version: 2.5.0
3
+ Version: 2.7.0
4
4
  Summary: Official Python SDK for the Hookbase webhook management platform
5
5
  Project-URL: Homepage, https://hookbase.app
6
6
  Project-URL: Documentation, https://www.hookbase.app/docs/send/sdk
@@ -27,9 +27,15 @@ logger = logging.getLogger("hookbase")
27
27
  def _parse_error(status_code: int, body: dict[str, Any], request_id: str | None) -> APIError:
28
28
  error = body.get("error", body)
29
29
  if isinstance(error, str):
30
+ # This is the shape the real API actually sends: `error` is a plain string, with
31
+ # `code` and `details` (Zod's `.flatten()` shape: `{ fieldErrors, formErrors }`) as
32
+ # siblings at the top level of the body, not nested inside `error`.
30
33
  message = error
31
- code = "unknown_error"
32
- validation_errors = None
34
+ code = body.get("code", "unknown_error")
35
+ details = body.get("details")
36
+ validation_errors = (
37
+ details.get("fieldErrors") if isinstance(details, dict) else None
38
+ )
33
39
  elif isinstance(error, dict):
34
40
  message = error.get("message") or body.get("message") or f"API error: {status_code}"
35
41
  code = error.get("code", "unknown_error")
@@ -0,0 +1 @@
1
+ __version__ = "2.7.0"
@@ -38,8 +38,11 @@ class APIError(HookbaseError):
38
38
  ) -> APIError:
39
39
  error = body.get("error", body)
40
40
  if isinstance(error, str):
41
+ # The real API always sends `error` as a plain string, with `code` and `details`
42
+ # (Zod's `.flatten()` shape: `{ fieldErrors, formErrors }`) as siblings of `error`
43
+ # at the top level of the body — never nested inside it.
41
44
  message = error
42
- code = "unknown_error"
45
+ code = body.get("code", "unknown_error")
43
46
  elif isinstance(error, dict):
44
47
  message = error.get("message", f"API error: {status_code}")
45
48
  code = error.get("code", "unknown_error")
@@ -49,7 +52,11 @@ class APIError(HookbaseError):
49
52
 
50
53
  if status_code in (400, 422):
51
54
  validation_errors = None
52
- if isinstance(error, dict):
55
+ if isinstance(error, str):
56
+ details = body.get("details")
57
+ if isinstance(details, dict):
58
+ validation_errors = details.get("fieldErrors")
59
+ elif isinstance(error, dict):
53
60
  validation_errors = error.get("validationErrors")
54
61
  return ValidationError(
55
62
  message=message,
@@ -7,6 +7,7 @@ from .cron_jobs import (
7
7
  CreateCronJobParams,
8
8
  CronGroup,
9
9
  CronJob,
10
+ CronJobExecution,
10
11
  UpdateCronJobParams,
11
12
  )
12
13
  from .deliveries import (
@@ -26,8 +27,11 @@ from .destinations import (
26
27
  GCSConfig,
27
28
  HttpMethod,
28
29
  R2Config,
30
+ RateUnit,
29
31
  S3Config,
30
32
  TestResult,
33
+ Throttle,
34
+ ThrottleMode,
31
35
  UpdateDestinationParams,
32
36
  )
33
37
  from .dlq import (
@@ -120,6 +124,7 @@ __all__ = [
120
124
  "CreateCronJobParams",
121
125
  "CronGroup",
122
126
  "CronJob",
127
+ "CronJobExecution",
123
128
  "UpdateCronJobParams",
124
129
  # Deliveries
125
130
  "BulkReplayResult",
@@ -137,8 +142,11 @@ __all__ = [
137
142
  "GCSConfig",
138
143
  "HttpMethod",
139
144
  "R2Config",
145
+ "RateUnit",
140
146
  "S3Config",
141
147
  "TestResult",
148
+ "Throttle",
149
+ "ThrottleMode",
142
150
  "UpdateDestinationParams",
143
151
  # DLQ
144
152
  "DlqBulkDeleteResult",
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from typing import Literal
4
5
 
5
6
  from pydantic import field_validator
6
7
 
7
8
  from ._base import HookbaseModel
8
9
 
10
+ CronJobExecutionStatus = Literal["success", "failed"]
11
+
9
12
 
10
13
  class CronJob(HookbaseModel):
11
14
  id: str
@@ -15,15 +18,19 @@ class CronJob(HookbaseModel):
15
18
  url: str = ""
16
19
  method: str = "GET"
17
20
  headers: dict[str, str] | None = None
18
- body: str | None = None
19
- schedule: str = ""
21
+ payload: str | None = None
22
+ cron_expression: str = ""
20
23
  timezone: str = "UTC"
24
+ timeout_ms: int = 30000
21
25
  is_active: bool = True
22
26
  use_static_ip: bool = True
23
27
  group_id: str | None = None
24
28
  last_run_at: str | None = None
25
29
  next_run_at: str | None = None
26
- last_status: str | None = None
30
+ consecutive_failures: int = 0
31
+ notify_on_failure: bool = False
32
+ notify_on_success: bool = False
33
+ notify_emails: str | None = None
27
34
  created_at: str = ""
28
35
  updated_at: str = ""
29
36
 
@@ -40,18 +47,29 @@ class CronJob(HookbaseModel):
40
47
  return v
41
48
 
42
49
 
50
+ class CronJobExecution(HookbaseModel):
51
+ id: str
52
+ status: CronJobExecutionStatus = "success"
53
+ response_status: int | None = None
54
+ latency_ms: int = 0
55
+
56
+
43
57
  class CreateCronJobParams(HookbaseModel):
44
58
  name: str
45
59
  description: str | None = None
46
60
  url: str
47
61
  method: str | None = None
48
62
  headers: dict[str, str] | None = None
49
- body: str | None = None
50
- schedule: str
63
+ payload: str | None = None
64
+ cron_expression: str
51
65
  timezone: str | None = None
66
+ timeout_ms: int | None = None
52
67
  is_active: bool | None = None
53
68
  use_static_ip: bool | None = None
54
69
  group_id: str | None = None
70
+ notify_on_failure: bool | None = None
71
+ notify_on_success: bool | None = None
72
+ notify_emails: str | None = None
55
73
 
56
74
 
57
75
  class UpdateCronJobParams(HookbaseModel):
@@ -60,12 +78,16 @@ class UpdateCronJobParams(HookbaseModel):
60
78
  url: str | None = None
61
79
  method: str | None = None
62
80
  headers: dict[str, str] | None = None
63
- body: str | None = None
64
- schedule: str | None = None
81
+ payload: str | None = None
82
+ cron_expression: str | None = None
65
83
  timezone: str | None = None
84
+ timeout_ms: int | None = None
66
85
  is_active: bool | None = None
67
86
  use_static_ip: bool | None = None
68
87
  group_id: str | None = None
88
+ notify_on_failure: bool | None = None
89
+ notify_on_success: bool | None = None
90
+ notify_emails: str | None = None
69
91
 
70
92
 
71
93
  class CronGroup(HookbaseModel):
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  from typing import Any, Literal
5
5
 
6
- from pydantic import field_validator
6
+ from pydantic import field_validator, model_validator
7
7
 
8
8
  from ._base import HookbaseModel
9
9
 
@@ -13,6 +13,8 @@ DestinationType = Literal["http", "s3", "r2", "gcs", "azure_blob"]
13
13
  FileFormat = Literal["json", "jsonl"]
14
14
  PartitionBy = Literal["date", "hour", "source"]
15
15
  FieldMappingType = Literal["string", "number", "boolean", "timestamp", "json"]
16
+ ThrottleMode = Literal["off", "rate", "concurrency"]
17
+ RateUnit = Literal["second", "minute", "hour"]
16
18
 
17
19
 
18
20
  class FieldMapping(HookbaseModel):
@@ -57,6 +59,14 @@ class AzureBlobConfig(HookbaseModel):
57
59
  partition_by: PartitionBy | None = None
58
60
 
59
61
 
62
+ class Throttle(HookbaseModel):
63
+ mode: ThrottleMode = "off"
64
+ rate_limit: int | None = None
65
+ rate_unit: RateUnit | None = None
66
+ max_concurrency: int | None = None
67
+ queue_limit: int | None = None
68
+
69
+
60
70
  class Destination(HookbaseModel):
61
71
  id: str
62
72
  organization_id: str | None = None
@@ -72,8 +82,7 @@ class Destination(HookbaseModel):
72
82
  timeout: int = 30
73
83
  retry_count: int = 3
74
84
  retry_interval: int = 60
75
- rate_limit: int | None = None
76
- rate_limit_window: int | None = None
85
+ throttle: Throttle | None = None
77
86
  is_active: bool = True
78
87
  use_static_ip: bool = True
79
88
  config: dict[str, Any] | None = None
@@ -85,6 +94,30 @@ class Destination(HookbaseModel):
85
94
  created_at: str = ""
86
95
  updated_at: str = ""
87
96
 
97
+ @model_validator(mode="before")
98
+ @classmethod
99
+ def _normalize_throttle(cls, data: Any) -> Any:
100
+ """Normalize the two response shapes the API uses for throttle data.
101
+
102
+ POST /api/destinations (create) and GET /api/destinations/export nest
103
+ throttle fields under a `throttle` object, but GET /api/destinations
104
+ (list) and GET /api/destinations/:id return them as flat top-level
105
+ `throttleMode`/`throttleRateLimit`/`throttleRateUnit`/
106
+ `throttleMaxConcurrency`/`throttleQueueLimit` fields (spread directly
107
+ from the DB row). Fold the flat shape into a nested `throttle` dict
108
+ so both response shapes populate the same field.
109
+ """
110
+ if isinstance(data, dict) and "throttle" not in data and "throttleMode" in data:
111
+ data = dict(data)
112
+ data["throttle"] = {
113
+ "mode": data.pop("throttleMode", None) or "off",
114
+ "rateLimit": data.pop("throttleRateLimit", None),
115
+ "rateUnit": data.pop("throttleRateUnit", None),
116
+ "maxConcurrency": data.pop("throttleMaxConcurrency", None),
117
+ "queueLimit": data.pop("throttleQueueLimit", None),
118
+ }
119
+ return data
120
+
88
121
  @field_validator("headers", mode="before")
89
122
  @classmethod
90
123
  def parse_headers(cls, v: Any) -> Any:
@@ -146,8 +179,7 @@ class CreateDestinationParams(HookbaseModel):
146
179
  timeout: int | None = None
147
180
  retry_count: int | None = None
148
181
  retry_interval: int | None = None
149
- rate_limit: int | None = None
150
- rate_limit_window: int | None = None
182
+ throttle: Throttle | None = None
151
183
  config: dict[str, Any] | None = None
152
184
  field_mapping: list[FieldMapping] | None = None
153
185
  use_static_ip: bool | None = None
@@ -166,8 +198,7 @@ class UpdateDestinationParams(HookbaseModel):
166
198
  timeout: int | None = None
167
199
  retry_count: int | None = None
168
200
  retry_interval: int | None = None
169
- rate_limit: int | None = None
170
- rate_limit_window: int | None = None
201
+ throttle: Throttle | None = None
171
202
  is_active: bool | None = None
172
203
  config: dict[str, Any] | None = None
173
204
  field_mapping: list[FieldMapping] | None = None
@@ -13,6 +13,7 @@ from ..models.cron_jobs import (
13
13
  CreateCronJobParams,
14
14
  CronGroup,
15
15
  CronJob,
16
+ CronJobExecution,
16
17
  UpdateCronJobParams,
17
18
  )
18
19
  from ._base import AsyncResource, SyncResource, _to_body
@@ -49,6 +50,11 @@ class CronJobs(SyncResource):
49
50
  def delete(self, id: str) -> None:
50
51
  self._request("DELETE", f"/api/cron/{id}")
51
52
 
53
+ def trigger(self, id: str) -> CronJobExecution:
54
+ resp = self._request("POST", f"/api/cron/{id}/trigger")
55
+ data = resp.get("execution", resp)
56
+ return self._parse(CronJobExecution, data)
57
+
52
58
  def list_groups(self) -> list[CronGroup]:
53
59
  resp = self._request("GET", "/api/cron-groups")
54
60
  items = resp.get("groups", resp.get("data", []))
@@ -92,6 +98,11 @@ class AsyncCronJobs(AsyncResource):
92
98
  async def delete(self, id: str) -> None:
93
99
  await self._request("DELETE", f"/api/cron/{id}")
94
100
 
101
+ async def trigger(self, id: str) -> CronJobExecution:
102
+ resp = await self._request("POST", f"/api/cron/{id}/trigger")
103
+ data = resp.get("execution", resp)
104
+ return self._parse(CronJobExecution, data)
105
+
95
106
  async def list_groups(self) -> list[CronGroup]:
96
107
  resp = await self._request("GET", "/api/cron-groups")
97
108
  items = resp.get("groups", resp.get("data", []))
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ import respx
5
+
6
+ from hookbase import Hookbase
7
+ from hookbase.models import CronJob, CronJobExecution
8
+
9
+ from ..conftest import make_paginated_response
10
+
11
+ CRON_JOB_DATA = {
12
+ "id": "cron_1",
13
+ "organizationId": "org_1",
14
+ "groupId": None,
15
+ "name": "Nightly sync",
16
+ "description": None,
17
+ "cronExpression": "0 0 * * *",
18
+ "timezone": "UTC",
19
+ "url": "https://example.com/sync",
20
+ "method": "POST",
21
+ "headers": None,
22
+ "payload": None,
23
+ "timeoutMs": 30000,
24
+ "useStaticIp": False,
25
+ "isActive": True,
26
+ "notifyOnFailure": True,
27
+ "notifyOnSuccess": False,
28
+ "notifyEmails": None,
29
+ "consecutiveFailures": 0,
30
+ "lastRunAt": "2026-09-01T00:00:00Z",
31
+ "nextRunAt": "2026-09-02T00:00:00Z",
32
+ "createdAt": "2026-01-01T00:00:00Z",
33
+ "updatedAt": "2026-09-01T00:00:00Z",
34
+ }
35
+
36
+
37
+ @pytest.fixture
38
+ def mock_api():
39
+ with respx.mock(base_url="https://api.hookbase.app") as mock:
40
+ yield mock
41
+
42
+
43
+ @pytest.fixture
44
+ def client(mock_api):
45
+ c = Hookbase(api_key="whr_test")
46
+ yield c
47
+ c.close()
48
+
49
+
50
+ def test_list_cron_jobs_uses_real_field_names(mock_api, client):
51
+ # GET /api/cron sends cronExpression, timezone, nextRunAt, lastRunAt and
52
+ # consecutiveFailures -- not the old, fabricated "schedule"/"lastStatus" fields.
53
+ mock_api.get("/api/cron").respond(200, json=make_paginated_response(
54
+ [CRON_JOB_DATA], data_key="cronJobs", total=1,
55
+ ))
56
+ page = client.cron_jobs.list()
57
+ assert len(page) == 1
58
+ job = page.data[0]
59
+ assert isinstance(job, CronJob)
60
+ assert job.cron_expression == "0 0 * * *"
61
+ assert job.next_run_at == "2026-09-02T00:00:00Z"
62
+ assert job.last_run_at == "2026-09-01T00:00:00Z"
63
+ assert job.consecutive_failures == 0
64
+ assert job.payload is None
65
+ assert not hasattr(job, "last_status")
66
+ assert not hasattr(job, "schedule")
67
+
68
+
69
+ def test_get_cron_job_uses_real_field_names(mock_api, client):
70
+ mock_api.get("/api/cron/cron_1").respond(200, json={"cronJob": CRON_JOB_DATA})
71
+ job = client.cron_jobs.get("cron_1")
72
+ assert job.cron_expression == "0 0 * * *"
73
+ assert job.timeout_ms == 30000
74
+ assert job.notify_on_failure is True
75
+ assert job.notify_on_success is False
76
+ assert job.consecutive_failures == 0
77
+
78
+
79
+ def test_create_cron_job(mock_api, client):
80
+ mock_api.post("/api/cron").respond(200, json={"cronJob": CRON_JOB_DATA})
81
+ job = client.cron_jobs.create({
82
+ "name": "Nightly sync",
83
+ "url": "https://example.com/sync",
84
+ "cron_expression": "0 0 * * *",
85
+ "payload": '{"foo": "bar"}',
86
+ })
87
+ assert isinstance(job, CronJob)
88
+ assert job.cron_expression == "0 0 * * *"
89
+
90
+
91
+ def test_update_cron_job(mock_api, client):
92
+ mock_api.patch("/api/cron/cron_1").respond(200, json={"success": True})
93
+ client.cron_jobs.update("cron_1", {"cron_expression": "*/5 * * * *", "payload": "{}"})
94
+
95
+
96
+ def test_delete_cron_job(mock_api, client):
97
+ mock_api.delete("/api/cron/cron_1").respond(200, json={"success": True})
98
+ client.cron_jobs.delete("cron_1")
99
+
100
+
101
+ def test_trigger_cron_job_calls_trigger_endpoint(mock_api, client):
102
+ route = mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
103
+ "execution": {
104
+ "id": "exec_1",
105
+ "status": "success",
106
+ "responseStatus": 200,
107
+ "latencyMs": 142,
108
+ }
109
+ })
110
+ result = client.cron_jobs.trigger("cron_1")
111
+ assert route.called
112
+ assert isinstance(result, CronJobExecution)
113
+ assert result.id == "exec_1"
114
+ assert result.status == "success"
115
+ assert result.response_status == 200
116
+ assert result.latency_ms == 142
117
+
118
+
119
+ def test_trigger_cron_job_failed_execution(mock_api, client):
120
+ mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
121
+ "execution": {
122
+ "id": "exec_2",
123
+ "status": "failed",
124
+ "responseStatus": None,
125
+ "latencyMs": 30000,
126
+ }
127
+ })
128
+ result = client.cron_jobs.trigger("cron_1")
129
+ assert result.status == "failed"
130
+ assert result.response_status is None
131
+
132
+
133
+ async def test_async_list_cron_jobs(mock_api):
134
+ from hookbase import AsyncHookbase
135
+
136
+ mock_api.get("/api/cron").respond(200, json=make_paginated_response(
137
+ [CRON_JOB_DATA], data_key="cronJobs", total=1,
138
+ ))
139
+ client = AsyncHookbase(api_key="whr_test")
140
+ page = await client.cron_jobs.list()
141
+ assert page.data[0].cron_expression == "0 0 * * *"
142
+ await client.close()
143
+
144
+
145
+ async def test_async_trigger_cron_job(mock_api):
146
+ from hookbase import AsyncHookbase
147
+
148
+ mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
149
+ "execution": {
150
+ "id": "exec_1",
151
+ "status": "success",
152
+ "responseStatus": 200,
153
+ "latencyMs": 99,
154
+ }
155
+ })
156
+ client = AsyncHookbase(api_key="whr_test")
157
+ result = await client.cron_jobs.trigger("cron_1")
158
+ assert isinstance(result, CronJobExecution)
159
+ assert result.latency_ms == 99
160
+ await client.close()
@@ -4,7 +4,14 @@ import pytest
4
4
  import respx
5
5
 
6
6
  from hookbase import Hookbase
7
- from hookbase.models import Destination, FieldMapping, TestResult
7
+ from hookbase.models import (
8
+ CreateDestinationParams,
9
+ Destination,
10
+ FieldMapping,
11
+ TestResult,
12
+ Throttle,
13
+ UpdateDestinationParams,
14
+ )
8
15
 
9
16
  from ..conftest import make_paginated_response
10
17
 
@@ -183,6 +190,87 @@ def test_destination_without_type_defaults():
183
190
  assert dest.field_mapping is None
184
191
 
185
192
 
193
+ def test_throttle_model():
194
+ throttle = Throttle(mode="rate", rate_limit=10, rate_unit="minute")
195
+ assert throttle.mode == "rate"
196
+ assert throttle.rate_limit == 10
197
+ assert throttle.rate_unit == "minute"
198
+ assert throttle.max_concurrency is None
199
+ assert throttle.queue_limit is None
200
+
201
+ default_throttle = Throttle()
202
+ assert default_throttle.mode == "off"
203
+
204
+
205
+ def test_create_destination_response_nests_throttle(mock_api, client):
206
+ """POST /api/destinations nests throttle fields under `throttle`."""
207
+ data = {
208
+ **DEST_DATA,
209
+ "throttle": {
210
+ "mode": "rate",
211
+ "rateLimit": 100,
212
+ "rateUnit": "minute",
213
+ "maxConcurrency": None,
214
+ "queueLimit": 50,
215
+ },
216
+ }
217
+ mock_api.post("/api/destinations").respond(200, json={"destination": data})
218
+ dest = client.destinations.create({"name": "Backend", "url": "https://example.com/webhooks"})
219
+ assert dest.throttle is not None
220
+ assert dest.throttle.mode == "rate"
221
+ assert dest.throttle.rate_limit == 100
222
+ assert dest.throttle.rate_unit == "minute"
223
+ assert dest.throttle.max_concurrency is None
224
+ assert dest.throttle.queue_limit == 50
225
+
226
+
227
+ def test_get_destination_response_has_flat_throttle_fields(mock_api, client):
228
+ """GET /api/destinations/:id returns throttle fields flat (spread from the
229
+ DB row) rather than nested under `throttle`; the model must normalize
230
+ this into the same `throttle` field used by the create/export shape."""
231
+ data = {
232
+ **DEST_DATA,
233
+ "throttleMode": "concurrency",
234
+ "throttleRateLimit": None,
235
+ "throttleRateUnit": None,
236
+ "throttleMaxConcurrency": 5,
237
+ "throttleQueueLimit": None,
238
+ }
239
+ mock_api.get("/api/destinations/dst_1").respond(200, json={"destination": data})
240
+ dest = client.destinations.get("dst_1")
241
+ assert dest.throttle is not None
242
+ assert dest.throttle.mode == "concurrency"
243
+ assert dest.throttle.max_concurrency == 5
244
+ assert dest.throttle.rate_limit is None
245
+
246
+
247
+ def test_destination_without_throttle_data():
248
+ dest = Destination(**DEST_DATA)
249
+ assert dest.throttle is None
250
+
251
+
252
+ def test_create_destination_params_throttle_serializes_camelcase():
253
+ params = CreateDestinationParams(
254
+ name="Backend",
255
+ url="https://example.com/webhooks",
256
+ throttle=Throttle(mode="rate", rate_limit=10, rate_unit="minute"),
257
+ )
258
+ body = params.model_dump(by_alias=True, exclude_none=True)
259
+ assert body["throttle"] == {"mode": "rate", "rateLimit": 10, "rateUnit": "minute"}
260
+
261
+
262
+ def test_update_destination_params_throttle_serializes_camelcase():
263
+ params = UpdateDestinationParams(
264
+ throttle=Throttle(mode="concurrency", max_concurrency=25, queue_limit=100),
265
+ )
266
+ body = params.model_dump(by_alias=True, exclude_none=True)
267
+ assert body["throttle"] == {
268
+ "mode": "concurrency",
269
+ "maxConcurrency": 25,
270
+ "queueLimit": 100,
271
+ }
272
+
273
+
186
274
  def test_test_destination(mock_api, client):
187
275
  mock_api.post("/api/destinations/dst_1/test").respond(200, json={
188
276
  "success": True, "statusCode": 200, "duration": 150.5,
@@ -30,16 +30,17 @@ def client(mock_api):
30
30
 
31
31
  def test_401_raises_authentication_error(mock_api, client):
32
32
  mock_api.get("/api/sources/src_1").respond(
33
- 401, json={"error": {"message": "Invalid API key"}}
33
+ 401, json={"error": "Invalid API key", "code": "authentication_error"}
34
34
  )
35
35
  with pytest.raises(AuthenticationError) as exc_info:
36
36
  client.sources.get("src_1")
37
37
  assert exc_info.value.status_code == 401
38
+ assert str(exc_info.value) == "Invalid API key"
38
39
 
39
40
 
40
41
  def test_403_raises_forbidden_error(mock_api, client):
41
42
  mock_api.get("/api/sources/src_1").respond(
42
- 403, json={"error": {"message": "Forbidden"}}
43
+ 403, json={"error": "Forbidden", "code": "forbidden"}
43
44
  )
44
45
  with pytest.raises(ForbiddenError):
45
46
  client.sources.get("src_1")
@@ -47,7 +48,7 @@ def test_403_raises_forbidden_error(mock_api, client):
47
48
 
48
49
  def test_404_raises_not_found_error(mock_api, client):
49
50
  mock_api.get("/api/sources/src_1").respond(
50
- 404, json={"error": {"message": "Not found"}}
51
+ 404, json={"error": "Not found", "code": "not_found"}
51
52
  )
52
53
  with pytest.raises(NotFoundError):
53
54
  client.sources.get("src_1")
@@ -56,17 +57,24 @@ def test_404_raises_not_found_error(mock_api, client):
56
57
  def test_400_raises_validation_error(mock_api, client):
57
58
  mock_api.post("/api/sources").respond(
58
59
  400,
59
- json={"error": {"message": "Name is required",
60
- "validationErrors": {"name": ["required"]}}},
60
+ json={
61
+ "error": "Name is required",
62
+ "code": "validation_error",
63
+ "details": {
64
+ "fieldErrors": {"name": ["required"]},
65
+ "formErrors": [],
66
+ },
67
+ },
61
68
  )
62
69
  with pytest.raises(ValidationError) as exc_info:
63
70
  client.sources.create({"name": ""})
64
71
  assert exc_info.value.validation_errors == {"name": ["required"]}
72
+ assert str(exc_info.value) == "Name is required"
65
73
 
66
74
 
67
75
  def test_409_raises_conflict_error(mock_api, client):
68
76
  mock_api.post("/api/sources").respond(
69
- 409, json={"error": {"message": "Slug already exists"}}
77
+ 409, json={"error": "Slug already exists", "code": "conflict"}
70
78
  )
71
79
  with pytest.raises(ConflictError):
72
80
  client.sources.create({"name": "test", "slug": "existing"})
@@ -75,7 +83,7 @@ def test_409_raises_conflict_error(mock_api, client):
75
83
  def test_429_raises_rate_limit_error(mock_api, client):
76
84
  mock_api.get("/api/sources/src_1").respond(
77
85
  429,
78
- json={"error": {"message": "Too many requests"}},
86
+ json={"error": "Too many requests", "code": "rate_limit_exceeded"},
79
87
  headers={"retry-after": "30"},
80
88
  )
81
89
  with pytest.raises(RateLimitError) as exc_info:
@@ -85,16 +93,45 @@ def test_429_raises_rate_limit_error(mock_api, client):
85
93
 
86
94
  def test_500_raises_api_error(mock_api, client):
87
95
  mock_api.get("/api/sources/src_1").respond(
88
- 500, json={"error": {"message": "Internal error"}}
96
+ 500, json={"error": "Internal error", "code": "internal_error"}
89
97
  )
90
98
  with pytest.raises(APIError) as exc_info:
91
99
  client.sources.get("src_1")
92
100
  assert exc_info.value.status_code == 500
101
+ # The real API's `code` is a body-level sibling of `error`, not nested inside it —
102
+ # make sure it's actually read rather than falling back to the generic default.
103
+ assert exc_info.value.code == "internal_error"
104
+
105
+
106
+ def test_error_code_falls_back_to_default_when_absent(mock_api, client):
107
+ mock_api.get("/api/sources/src_1").respond(500, json={"error": "Internal error"})
108
+ with pytest.raises(APIError) as exc_info:
109
+ client.sources.get("src_1")
110
+ assert exc_info.value.code == "unknown_error"
93
111
 
94
112
 
95
113
  def test_error_from_response_string_error():
96
- err = APIError.from_response(500, {"error": "Server error"})
114
+ err = APIError.from_response(500, {"error": "Server error", "code": "server_error"})
97
115
  assert str(err) == "Server error"
116
+ assert err.code == "server_error"
117
+
118
+
119
+ def test_error_from_response_string_error_default_code():
120
+ err = APIError.from_response(500, {"error": "Server error"})
121
+ assert err.code == "unknown_error"
122
+
123
+
124
+ def test_error_from_response_validation_details():
125
+ err = APIError.from_response(
126
+ 400,
127
+ {
128
+ "error": "Name is required",
129
+ "code": "validation_error",
130
+ "details": {"fieldErrors": {"name": ["required"]}},
131
+ },
132
+ )
133
+ assert isinstance(err, ValidationError)
134
+ assert err.validation_errors == {"name": ["required"]}
98
135
 
99
136
 
100
137
  def test_error_from_response_dict_error():
@@ -102,3 +139,4 @@ def test_error_from_response_dict_error():
102
139
  500, {"error": {"message": "Detailed error", "code": "server_error"}},
103
140
  )
104
141
  assert str(err) == "Detailed error"
142
+ assert err.code == "server_error"
@@ -1 +0,0 @@
1
- __version__ = "2.5.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes