bookalimo 0.1.4__py3-none-any.whl → 1.0.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.
- bookalimo/__init__.py +17 -24
- bookalimo/_version.py +9 -0
- bookalimo/client.py +310 -0
- bookalimo/config.py +16 -0
- bookalimo/exceptions.py +115 -5
- bookalimo/integrations/__init__.py +1 -0
- bookalimo/integrations/google_places/__init__.py +31 -0
- bookalimo/integrations/google_places/client_async.py +258 -0
- bookalimo/integrations/google_places/client_sync.py +257 -0
- bookalimo/integrations/google_places/common.py +245 -0
- bookalimo/integrations/google_places/proto_adapter.py +224 -0
- bookalimo/{_logging.py → logging.py} +59 -62
- bookalimo/schemas/__init__.py +97 -0
- bookalimo/schemas/base.py +56 -0
- bookalimo/{models.py → schemas/booking.py} +88 -100
- bookalimo/schemas/places/__init__.py +37 -0
- bookalimo/schemas/places/common.py +198 -0
- bookalimo/schemas/places/google.py +596 -0
- bookalimo/schemas/places/place.py +337 -0
- bookalimo/services/__init__.py +11 -0
- bookalimo/services/pricing.py +191 -0
- bookalimo/services/reservations.py +227 -0
- bookalimo/transport/__init__.py +7 -0
- bookalimo/transport/auth.py +41 -0
- bookalimo/transport/base.py +44 -0
- bookalimo/transport/httpx_async.py +230 -0
- bookalimo/transport/httpx_sync.py +230 -0
- bookalimo/transport/retry.py +102 -0
- bookalimo/transport/utils.py +59 -0
- bookalimo-1.0.0.dist-info/METADATA +307 -0
- bookalimo-1.0.0.dist-info/RECORD +35 -0
- bookalimo/_client.py +0 -420
- bookalimo/wrapper.py +0 -444
- bookalimo-0.1.4.dist-info/METADATA +0 -392
- bookalimo-0.1.4.dist-info/RECORD +0 -12
- {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/WHEEL +0 -0
- {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,227 @@
|
|
1
|
+
"""Reservations service for listing, getting, editing, and booking reservations."""
|
2
|
+
|
3
|
+
from typing import Any, Optional
|
4
|
+
|
5
|
+
from ..exceptions import BookalimoRequestError
|
6
|
+
from ..schemas.booking import (
|
7
|
+
BookRequest,
|
8
|
+
BookResponse,
|
9
|
+
CreditCard,
|
10
|
+
EditableReservationRequest,
|
11
|
+
EditReservationResponse,
|
12
|
+
GetReservationRequest,
|
13
|
+
GetReservationResponse,
|
14
|
+
ListReservationsRequest,
|
15
|
+
ListReservationsResponse,
|
16
|
+
)
|
17
|
+
from ..transport.base import AsyncBaseTransport, BaseTransport
|
18
|
+
|
19
|
+
|
20
|
+
class AsyncReservationsService:
|
21
|
+
"""Async reservations service."""
|
22
|
+
|
23
|
+
def __init__(self, transport: AsyncBaseTransport):
|
24
|
+
self._transport = transport
|
25
|
+
|
26
|
+
async def list(self, is_archive: bool = False) -> ListReservationsResponse:
|
27
|
+
"""
|
28
|
+
List reservations for the user.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
is_archive: If True, fetch archived reservations
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
ListReservationsResponse with reservations list
|
35
|
+
"""
|
36
|
+
request = ListReservationsRequest(is_archive=is_archive)
|
37
|
+
return await self._transport.post(
|
38
|
+
"/booking/reservation/list/", request, ListReservationsResponse
|
39
|
+
)
|
40
|
+
|
41
|
+
async def get(self, confirmation: str) -> GetReservationResponse:
|
42
|
+
"""
|
43
|
+
Get detailed reservation information.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
confirmation: Confirmation number
|
47
|
+
|
48
|
+
Returns:
|
49
|
+
GetReservationResponse with reservation details
|
50
|
+
"""
|
51
|
+
request = GetReservationRequest(confirmation=confirmation)
|
52
|
+
return await self._transport.post(
|
53
|
+
"/booking/reservation/get/", request, GetReservationResponse
|
54
|
+
)
|
55
|
+
|
56
|
+
async def edit(
|
57
|
+
self, confirmation: str, *, is_cancel: bool = False, **changes: Any
|
58
|
+
) -> EditReservationResponse:
|
59
|
+
"""
|
60
|
+
Edit or cancel a reservation.
|
61
|
+
|
62
|
+
Args:
|
63
|
+
confirmation: Confirmation number
|
64
|
+
is_cancel: True to cancel the reservation
|
65
|
+
**changes: Fields to change (rate_type, pickup_date, pickup_time,
|
66
|
+
stops, passengers, luggage, pets, car_seats, boosters,
|
67
|
+
infants, other)
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
EditReservationResponse
|
71
|
+
"""
|
72
|
+
request_data = {
|
73
|
+
"confirmation": confirmation,
|
74
|
+
"is_cancel_request": is_cancel,
|
75
|
+
}
|
76
|
+
|
77
|
+
# Add changes if not canceling
|
78
|
+
if not is_cancel:
|
79
|
+
for key, value in changes.items():
|
80
|
+
if value is not None:
|
81
|
+
request_data[key] = value
|
82
|
+
|
83
|
+
request = EditableReservationRequest.model_validate(request_data)
|
84
|
+
return await self._transport.post(
|
85
|
+
"/booking/edit/", request, EditReservationResponse
|
86
|
+
)
|
87
|
+
|
88
|
+
async def book(
|
89
|
+
self,
|
90
|
+
token: str,
|
91
|
+
*,
|
92
|
+
method: Optional[str] = None,
|
93
|
+
credit_card: Optional[CreditCard] = None,
|
94
|
+
promo: Optional[str] = None,
|
95
|
+
) -> BookResponse:
|
96
|
+
"""
|
97
|
+
Book a reservation.
|
98
|
+
|
99
|
+
Args:
|
100
|
+
token: Session token from pricing.quote() or pricing.update_details()
|
101
|
+
method: 'charge' for charge accounts, None for credit card
|
102
|
+
credit_card: Credit card information (required if method is not 'charge')
|
103
|
+
promo: Optional promo code
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
BookResponse with reservation_id
|
107
|
+
"""
|
108
|
+
request_data: dict[str, Any] = {"token": token}
|
109
|
+
|
110
|
+
if promo:
|
111
|
+
request_data["promo"] = promo
|
112
|
+
|
113
|
+
if method == "charge":
|
114
|
+
request_data["method"] = "charge"
|
115
|
+
elif credit_card:
|
116
|
+
request_data["credit_card"] = credit_card
|
117
|
+
else:
|
118
|
+
raise BookalimoRequestError(
|
119
|
+
"Either method='charge' or credit_card must be provided"
|
120
|
+
)
|
121
|
+
|
122
|
+
request = BookRequest(**request_data)
|
123
|
+
return await self._transport.post("/booking/book/", request, BookResponse)
|
124
|
+
|
125
|
+
|
126
|
+
class ReservationsService:
|
127
|
+
"""Sync reservations service."""
|
128
|
+
|
129
|
+
def __init__(self, transport: BaseTransport):
|
130
|
+
self._transport = transport
|
131
|
+
|
132
|
+
def list(self, is_archive: bool = False) -> ListReservationsResponse:
|
133
|
+
"""
|
134
|
+
List reservations for the user.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
is_archive: If True, fetch archived reservations
|
138
|
+
|
139
|
+
Returns:
|
140
|
+
ListReservationsResponse with reservations list
|
141
|
+
"""
|
142
|
+
request = ListReservationsRequest(is_archive=is_archive)
|
143
|
+
return self._transport.post(
|
144
|
+
"/booking/reservation/list/", request, ListReservationsResponse
|
145
|
+
)
|
146
|
+
|
147
|
+
def get(self, confirmation: str) -> GetReservationResponse:
|
148
|
+
"""
|
149
|
+
Get detailed reservation information.
|
150
|
+
|
151
|
+
Args:
|
152
|
+
confirmation: Confirmation number
|
153
|
+
|
154
|
+
Returns:
|
155
|
+
GetReservationResponse with reservation details
|
156
|
+
"""
|
157
|
+
request = GetReservationRequest(confirmation=confirmation)
|
158
|
+
return self._transport.post(
|
159
|
+
"/booking/reservation/get/", request, GetReservationResponse
|
160
|
+
)
|
161
|
+
|
162
|
+
def edit(
|
163
|
+
self, confirmation: str, *, is_cancel: bool = False, **changes: Any
|
164
|
+
) -> EditReservationResponse:
|
165
|
+
"""
|
166
|
+
Edit or cancel a reservation.
|
167
|
+
|
168
|
+
Args:
|
169
|
+
confirmation: Confirmation number
|
170
|
+
is_cancel: True to cancel the reservation
|
171
|
+
**changes: Fields to change (rate_type, pickup_date, pickup_time,
|
172
|
+
stops, passengers, luggage, pets, car_seats, boosters,
|
173
|
+
infants, other)
|
174
|
+
|
175
|
+
Returns:
|
176
|
+
EditReservationResponse
|
177
|
+
"""
|
178
|
+
request_data = {
|
179
|
+
"confirmation": confirmation,
|
180
|
+
"is_cancel_request": is_cancel,
|
181
|
+
}
|
182
|
+
|
183
|
+
# Add changes if not canceling
|
184
|
+
if not is_cancel:
|
185
|
+
for key, value in changes.items():
|
186
|
+
if value is not None:
|
187
|
+
request_data[key] = value
|
188
|
+
|
189
|
+
request = EditableReservationRequest.model_validate(request_data)
|
190
|
+
return self._transport.post("/booking/edit/", request, EditReservationResponse)
|
191
|
+
|
192
|
+
def book(
|
193
|
+
self,
|
194
|
+
token: str,
|
195
|
+
*,
|
196
|
+
method: Optional[str] = None,
|
197
|
+
credit_card: Optional[CreditCard] = None,
|
198
|
+
promo: Optional[str] = None,
|
199
|
+
) -> BookResponse:
|
200
|
+
"""
|
201
|
+
Book a reservation.
|
202
|
+
|
203
|
+
Args:
|
204
|
+
token: Session token from pricing.quote() or pricing.update_details()
|
205
|
+
method: 'charge' for charge accounts, None for credit card
|
206
|
+
credit_card: Credit card information (required if method is not 'charge')
|
207
|
+
promo: Optional promo code
|
208
|
+
|
209
|
+
Returns:
|
210
|
+
BookResponse with reservation_id
|
211
|
+
"""
|
212
|
+
request_data: dict[str, Any] = {"token": token}
|
213
|
+
|
214
|
+
if promo:
|
215
|
+
request_data["promo"] = promo
|
216
|
+
|
217
|
+
if method == "charge":
|
218
|
+
request_data["method"] = "charge"
|
219
|
+
elif credit_card:
|
220
|
+
request_data["credit_card"] = credit_card
|
221
|
+
else:
|
222
|
+
raise BookalimoRequestError(
|
223
|
+
"Either method='charge' or credit_card must be provided"
|
224
|
+
)
|
225
|
+
|
226
|
+
request = BookRequest.model_validate(request_data)
|
227
|
+
return self._transport.post("/booking/book/", request, BookResponse)
|
@@ -0,0 +1,41 @@
|
|
1
|
+
"""Authentication and credential handling."""
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
from typing import Any, Optional
|
5
|
+
|
6
|
+
from ..schemas.base import ApiModel
|
7
|
+
|
8
|
+
|
9
|
+
class Credentials(ApiModel):
|
10
|
+
"""Authentication credentials for Book-A-Limo API."""
|
11
|
+
|
12
|
+
id: str
|
13
|
+
password_hash: str
|
14
|
+
is_customer: bool = False
|
15
|
+
|
16
|
+
@classmethod
|
17
|
+
def create_hash(cls, password: str, user_id: str) -> str:
|
18
|
+
"""Create password hash as required by API: Sha256(Sha256(Password) + LowerCase(Id))"""
|
19
|
+
inner_hash = hashlib.sha256(password.encode()).hexdigest()
|
20
|
+
full_string = inner_hash + user_id.lower()
|
21
|
+
return hashlib.sha256(full_string.encode()).hexdigest()
|
22
|
+
|
23
|
+
@classmethod
|
24
|
+
def create(
|
25
|
+
cls, user_id: str, password: str, is_customer: bool = False
|
26
|
+
) -> "Credentials":
|
27
|
+
"""Create credentials with automatic password hashing."""
|
28
|
+
return cls(
|
29
|
+
id=user_id,
|
30
|
+
password_hash=cls.create_hash(password, user_id),
|
31
|
+
is_customer=is_customer,
|
32
|
+
)
|
33
|
+
|
34
|
+
|
35
|
+
def inject_credentials(
|
36
|
+
data: dict[str, Any], credentials: Optional[Credentials]
|
37
|
+
) -> dict[str, Any]:
|
38
|
+
"""Inject credentials into request data if provided."""
|
39
|
+
if credentials:
|
40
|
+
data["credentials"] = credentials.model_dump()
|
41
|
+
return data
|
@@ -0,0 +1,44 @@
|
|
1
|
+
"""Base transport interface and shared utilities."""
|
2
|
+
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
from typing import Any, TypeVar
|
5
|
+
|
6
|
+
from pydantic import BaseModel
|
7
|
+
|
8
|
+
T = TypeVar("T", bound=BaseModel)
|
9
|
+
|
10
|
+
|
11
|
+
class BaseTransport(ABC):
|
12
|
+
"""Base transport interface for sync and async implementations."""
|
13
|
+
|
14
|
+
@abstractmethod
|
15
|
+
def post(self, path: str, model: BaseModel, response_model: type[T]) -> T:
|
16
|
+
"""Make a POST request and return parsed response."""
|
17
|
+
...
|
18
|
+
|
19
|
+
@abstractmethod
|
20
|
+
def close(self) -> None:
|
21
|
+
"""Close the transport and clean up resources."""
|
22
|
+
...
|
23
|
+
|
24
|
+
def prepare_data(self, data: BaseModel) -> dict[str, Any]:
|
25
|
+
"""Prepare data for API requests by converting it to the appropriate format."""
|
26
|
+
return data.model_dump(mode="json", exclude_none=True)
|
27
|
+
|
28
|
+
|
29
|
+
class AsyncBaseTransport(ABC):
|
30
|
+
"""Base async transport interface."""
|
31
|
+
|
32
|
+
@abstractmethod
|
33
|
+
async def post(self, path: str, model: BaseModel, response_model: type[T]) -> T:
|
34
|
+
"""Make a POST request and return parsed response."""
|
35
|
+
...
|
36
|
+
|
37
|
+
@abstractmethod
|
38
|
+
async def aclose(self) -> None:
|
39
|
+
"""Close the transport and clean up resources."""
|
40
|
+
...
|
41
|
+
|
42
|
+
def prepare_data(self, data: BaseModel) -> dict[str, Any]:
|
43
|
+
"""Prepare data for API requests by converting it to the appropriate format."""
|
44
|
+
return data.model_dump(mode="json", exclude_none=True)
|
@@ -0,0 +1,230 @@
|
|
1
|
+
"""Async HTTP transport using httpx."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from time import perf_counter
|
5
|
+
from typing import Any, Optional, TypeVar, Union, overload
|
6
|
+
from uuid import uuid4
|
7
|
+
|
8
|
+
import httpx
|
9
|
+
from pydantic import BaseModel
|
10
|
+
|
11
|
+
from ..config import (
|
12
|
+
DEFAULT_BACKOFF,
|
13
|
+
DEFAULT_BASE_URL,
|
14
|
+
DEFAULT_RETRIES,
|
15
|
+
DEFAULT_TIMEOUTS,
|
16
|
+
DEFAULT_USER_AGENT,
|
17
|
+
)
|
18
|
+
from ..exceptions import (
|
19
|
+
BookalimoConnectionError,
|
20
|
+
BookalimoError,
|
21
|
+
BookalimoHTTPError,
|
22
|
+
BookalimoRequestError,
|
23
|
+
BookalimoTimeout,
|
24
|
+
)
|
25
|
+
from .auth import Credentials, inject_credentials
|
26
|
+
from .base import AsyncBaseTransport
|
27
|
+
from .retry import async_retry, should_retry_exception, should_retry_status
|
28
|
+
from .utils import handle_api_errors, handle_http_error
|
29
|
+
|
30
|
+
logger = logging.getLogger("bookalimo.transport")
|
31
|
+
|
32
|
+
T = TypeVar("T", bound=BaseModel)
|
33
|
+
|
34
|
+
|
35
|
+
class AsyncTransport(AsyncBaseTransport):
|
36
|
+
"""Async HTTP transport using httpx."""
|
37
|
+
|
38
|
+
def __init__(
|
39
|
+
self,
|
40
|
+
base_url: str = DEFAULT_BASE_URL,
|
41
|
+
timeouts: Any = DEFAULT_TIMEOUTS,
|
42
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
43
|
+
credentials: Optional[Credentials] = None,
|
44
|
+
client: Optional[httpx.AsyncClient] = None,
|
45
|
+
retries: int = DEFAULT_RETRIES,
|
46
|
+
backoff: float = DEFAULT_BACKOFF,
|
47
|
+
):
|
48
|
+
self.base_url = base_url.rstrip("/")
|
49
|
+
self.credentials = credentials
|
50
|
+
self.retries = retries
|
51
|
+
self.backoff = backoff
|
52
|
+
self.headers = {
|
53
|
+
"content-type": "application/json",
|
54
|
+
"user-agent": user_agent,
|
55
|
+
}
|
56
|
+
|
57
|
+
# Create client if not provided
|
58
|
+
self._owns_client = client is None
|
59
|
+
self.client = client or httpx.AsyncClient(timeout=timeouts)
|
60
|
+
|
61
|
+
if logger.isEnabledFor(logging.DEBUG):
|
62
|
+
logger.debug(
|
63
|
+
"AsyncTransport initialized (base_url=%s, timeout=%s, user_agent=%s)",
|
64
|
+
self.base_url,
|
65
|
+
timeouts,
|
66
|
+
user_agent,
|
67
|
+
)
|
68
|
+
|
69
|
+
@overload
|
70
|
+
async def post(self, path: str, model: BaseModel) -> Any: ...
|
71
|
+
@overload
|
72
|
+
async def post(self, path: str, model: BaseModel, response_model: type[T]) -> T: ...
|
73
|
+
|
74
|
+
async def post(
|
75
|
+
self, path: str, model: BaseModel, response_model: Optional[type[T]] = None
|
76
|
+
) -> Union[T, Any]:
|
77
|
+
"""Make a POST request and return parsed response."""
|
78
|
+
# Prepare URL
|
79
|
+
path = path if path.startswith("/") else f"/{path}"
|
80
|
+
url = f"{self.base_url}{path}"
|
81
|
+
|
82
|
+
# Prepare data and inject credentials
|
83
|
+
data = self.prepare_data(model)
|
84
|
+
data = inject_credentials(data, self.credentials)
|
85
|
+
|
86
|
+
# Debug logging
|
87
|
+
req_id = None
|
88
|
+
start = 0.0
|
89
|
+
if logger.isEnabledFor(logging.DEBUG):
|
90
|
+
req_id = uuid4().hex[:8]
|
91
|
+
start = perf_counter()
|
92
|
+
body_keys = sorted(k for k in data.keys() if k != "credentials")
|
93
|
+
logger.debug(
|
94
|
+
"→ [%s] POST %s body_keys=%s",
|
95
|
+
req_id,
|
96
|
+
path,
|
97
|
+
body_keys,
|
98
|
+
)
|
99
|
+
|
100
|
+
try:
|
101
|
+
# Make request with retry logic
|
102
|
+
response = await async_retry(
|
103
|
+
lambda: self._make_request(url, data),
|
104
|
+
retries=self.retries,
|
105
|
+
backoff=self.backoff,
|
106
|
+
should_retry=lambda e: should_retry_exception(e)
|
107
|
+
or (
|
108
|
+
isinstance(e, httpx.HTTPStatusError)
|
109
|
+
and should_retry_status(e.response.status_code)
|
110
|
+
),
|
111
|
+
)
|
112
|
+
|
113
|
+
# Handle HTTP errors
|
114
|
+
if response.status_code >= 400:
|
115
|
+
handle_http_error(response, req_id, path)
|
116
|
+
|
117
|
+
# Parse JSON
|
118
|
+
try:
|
119
|
+
json_data = response.json()
|
120
|
+
except ValueError as e:
|
121
|
+
if logger.isEnabledFor(logging.DEBUG):
|
122
|
+
logger.warning("× [%s] %s invalid JSON", req_id or "-", path)
|
123
|
+
preview = (
|
124
|
+
(response.text or "")[:256] if hasattr(response, "text") else None
|
125
|
+
)
|
126
|
+
raise BookalimoError(
|
127
|
+
f"Invalid JSON response: {preview}",
|
128
|
+
) from e
|
129
|
+
|
130
|
+
# Handle API-level errors
|
131
|
+
handle_api_errors(json_data, req_id, path)
|
132
|
+
|
133
|
+
# Debug logging for success
|
134
|
+
if logger.isEnabledFor(logging.DEBUG):
|
135
|
+
dur_ms = (perf_counter() - start) * 1000.0
|
136
|
+
reqid_hdr = response.headers.get(
|
137
|
+
"x-request-id"
|
138
|
+
) or response.headers.get("request-id")
|
139
|
+
content_len = (
|
140
|
+
len(response.content) if hasattr(response, "content") else None
|
141
|
+
)
|
142
|
+
logger.debug(
|
143
|
+
"← [%s] %s %s in %.1f ms len=%s reqid=%s",
|
144
|
+
req_id,
|
145
|
+
response.status_code,
|
146
|
+
path,
|
147
|
+
dur_ms,
|
148
|
+
content_len,
|
149
|
+
reqid_hdr,
|
150
|
+
)
|
151
|
+
|
152
|
+
# Parse and return response
|
153
|
+
return (
|
154
|
+
response_model.model_validate(json_data)
|
155
|
+
if response_model
|
156
|
+
else json_data
|
157
|
+
)
|
158
|
+
|
159
|
+
except httpx.TimeoutException:
|
160
|
+
if logger.isEnabledFor(logging.DEBUG):
|
161
|
+
logger.warning("× [%s] %s timeout", req_id or "-", path)
|
162
|
+
raise BookalimoTimeout("Request timeout") from None
|
163
|
+
|
164
|
+
except httpx.ConnectError:
|
165
|
+
if logger.isEnabledFor(logging.DEBUG):
|
166
|
+
logger.warning("× [%s] %s connection error", req_id or "-", path)
|
167
|
+
raise BookalimoConnectionError(
|
168
|
+
"Connection error - unable to reach Book-A-Limo API"
|
169
|
+
) from None
|
170
|
+
except httpx.RequestError as e:
|
171
|
+
if logger.isEnabledFor(logging.DEBUG):
|
172
|
+
logger.warning(
|
173
|
+
"× [%s] %s request error: %s",
|
174
|
+
req_id or "-",
|
175
|
+
path,
|
176
|
+
e.__class__.__name__,
|
177
|
+
)
|
178
|
+
raise BookalimoRequestError(f"Request Error: {e}") from e
|
179
|
+
|
180
|
+
except httpx.HTTPStatusError as e:
|
181
|
+
if logger.isEnabledFor(logging.DEBUG):
|
182
|
+
logger.warning(
|
183
|
+
"× [%s] %s HTTP error: %s",
|
184
|
+
req_id or "-",
|
185
|
+
path,
|
186
|
+
e.__class__.__name__,
|
187
|
+
)
|
188
|
+
status_code = getattr(getattr(e, "response", None), "status_code", None)
|
189
|
+
raise BookalimoHTTPError(f"HTTP Error: {e}", status_code=status_code) from e
|
190
|
+
|
191
|
+
except (BookalimoError, BookalimoHTTPError):
|
192
|
+
# Already handled above
|
193
|
+
raise
|
194
|
+
|
195
|
+
except Exception as e:
|
196
|
+
if logger.isEnabledFor(logging.DEBUG):
|
197
|
+
logger.warning(
|
198
|
+
"× [%s] %s unexpected error: %s",
|
199
|
+
req_id or "-",
|
200
|
+
path,
|
201
|
+
e.__class__.__name__,
|
202
|
+
)
|
203
|
+
raise BookalimoError(f"Unexpected error: {str(e)}") from e
|
204
|
+
|
205
|
+
async def _make_request(self, url: str, data: dict[str, Any]) -> httpx.Response:
|
206
|
+
"""Make the actual HTTP request."""
|
207
|
+
resp = await self.client.post(url, json=data, headers=self.headers)
|
208
|
+
if should_retry_status(resp.status_code):
|
209
|
+
# Construct an HTTPStatusError so async_retry can catch & decide.
|
210
|
+
raise httpx.HTTPStatusError(
|
211
|
+
message=f"Retryable HTTP status: {resp.status_code}",
|
212
|
+
request=resp.request,
|
213
|
+
response=resp,
|
214
|
+
)
|
215
|
+
return resp
|
216
|
+
|
217
|
+
async def aclose(self) -> None:
|
218
|
+
"""Close the HTTP client if we own it."""
|
219
|
+
if self._owns_client and not self.client.is_closed:
|
220
|
+
await self.client.aclose()
|
221
|
+
if logger.isEnabledFor(logging.DEBUG):
|
222
|
+
logger.debug("AsyncTransport HTTP client closed")
|
223
|
+
|
224
|
+
async def __aenter__(self) -> "AsyncTransport":
|
225
|
+
"""Async context manager entry."""
|
226
|
+
return self
|
227
|
+
|
228
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
229
|
+
"""Async context manager exit."""
|
230
|
+
await self.aclose()
|