booking-agent-cli 0.1.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.
Files changed (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,178 @@
1
+ """HTTP retry helpers with backoff for merchant, registry, and OAuth calls."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import random
7
+ from datetime import datetime, timezone
8
+ from email.utils import parsedate_to_datetime
9
+ from typing import Awaitable, Callable, Optional, Set, TypeVar
10
+
11
+ import httpx
12
+
13
+ from . import settings
14
+ from booking_agent.observability import emit_event
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Retry overload and gateway failures. Do not retry 501/505 (the method or
19
+ # version will not start working) or 4xx other than 429.
20
+ RETRYABLE_STATUS_CODES: Set[int] = {429, 500, 502, 503, 504}
21
+
22
+ # Connect failures never reached the peer, so they are safe to retry even on
23
+ # POST. ReadTimeout is excluded: the server may already have processed the body.
24
+ RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = (
25
+ httpx.ConnectTimeout,
26
+ httpx.ConnectError,
27
+ httpx.PoolTimeout,
28
+ )
29
+
30
+ T = TypeVar("T")
31
+
32
+
33
+ class RateLimitedError(httpx.HTTPStatusError):
34
+ """Raised when HTTP 429 persists after all retry attempts."""
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ *,
40
+ request: httpx.Request,
41
+ response: httpx.Response,
42
+ retry_after_seconds: Optional[float] = None,
43
+ ) -> None:
44
+ super().__init__(message, request=request, response=response)
45
+ self.retry_after_seconds = retry_after_seconds
46
+
47
+
48
+ def _max_attempts() -> int:
49
+ return settings.get_int("BOOKING_AGENT_HTTP_MAX_RETRIES", minimum=1)
50
+
51
+
52
+ def _base_delay() -> float:
53
+ return settings.get_float("BOOKING_AGENT_HTTP_BACKOFF_BASE", minimum=0.1)
54
+
55
+
56
+ def _max_delay() -> float:
57
+ return settings.get_float("BOOKING_AGENT_HTTP_BACKOFF_MAX", minimum=1.0)
58
+
59
+
60
+ def retry_after_seconds(response: httpx.Response) -> Optional[float]:
61
+ """Parse ``Retry-After`` as seconds delay, or None if absent/unparseable."""
62
+ raw = response.headers.get("Retry-After")
63
+ if not raw or not str(raw).strip():
64
+ return None
65
+ text = str(raw).strip()
66
+ try:
67
+ return max(0.0, float(text))
68
+ except ValueError:
69
+ pass
70
+ try:
71
+ dt = parsedate_to_datetime(text)
72
+ if dt.tzinfo is None:
73
+ dt = dt.replace(tzinfo=timezone.utc)
74
+ return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds())
75
+ except (TypeError, ValueError, OverflowError):
76
+ return None
77
+
78
+
79
+ def _jittered_delay(base: float) -> float:
80
+ return base * (0.5 + random.random())
81
+
82
+
83
+ async def with_http_retry(
84
+ request_fn: Callable[[], Awaitable[httpx.Response]],
85
+ *,
86
+ max_attempts: Optional[int] = None,
87
+ base_delay: Optional[float] = None,
88
+ max_delay: Optional[float] = None,
89
+ ) -> httpx.Response:
90
+ """Execute *request_fn* with retries on 429/5xx and connect failures.
91
+
92
+ *request_fn* must be a zero-argument async callable returning an
93
+ :class:`httpx.Response` (without raising on non-2xx status). Connect
94
+ timeouts and connection errors are retried; read timeouts are not.
95
+ """
96
+ attempts = max_attempts if max_attempts is not None else _max_attempts()
97
+ delay_base = base_delay if base_delay is not None else _base_delay()
98
+ delay_cap = max_delay if max_delay is not None else _max_delay()
99
+
100
+ delay = delay_base
101
+ last_response: Optional[httpx.Response] = None
102
+
103
+ for attempt in range(attempts):
104
+ try:
105
+ response = await request_fn()
106
+ except RETRYABLE_EXCEPTIONS as exc:
107
+ last_response = None
108
+ if attempt >= attempts - 1:
109
+ raise
110
+ wait = _jittered_delay(min(delay, delay_cap))
111
+ logger.warning(
112
+ "%s on outbound HTTP - retry %d/%d in %.1fs",
113
+ type(exc).__name__,
114
+ attempt + 1,
115
+ attempts,
116
+ wait,
117
+ )
118
+ emit_event(
119
+ "http_retry",
120
+ attempt=attempt + 1,
121
+ max_attempts=attempts,
122
+ reason=type(exc).__name__,
123
+ )
124
+ await asyncio.sleep(wait)
125
+ delay = min(delay * 2, delay_cap)
126
+ continue
127
+
128
+ last_response = response
129
+ if response.status_code not in RETRYABLE_STATUS_CODES:
130
+ return response
131
+ if attempt >= attempts - 1:
132
+ break
133
+ retry_after = retry_after_seconds(response)
134
+ wait = retry_after if retry_after is not None else min(delay, delay_cap)
135
+ wait = _jittered_delay(wait)
136
+ logger.warning(
137
+ "HTTP %s from %s - retry %d/%d in %.1fs",
138
+ response.status_code,
139
+ response.request.url,
140
+ attempt + 1,
141
+ attempts,
142
+ wait,
143
+ )
144
+ emit_event(
145
+ "http_retry",
146
+ attempt=attempt + 1,
147
+ max_attempts=attempts,
148
+ status=response.status_code,
149
+ )
150
+ await asyncio.sleep(wait)
151
+ delay = min(delay * 2, delay_cap)
152
+
153
+ assert last_response is not None
154
+ if last_response.status_code == 429:
155
+ raise RateLimitedError(
156
+ f"Rate limited by {last_response.request.url}",
157
+ request=last_response.request,
158
+ response=last_response,
159
+ retry_after_seconds=retry_after_seconds(last_response),
160
+ )
161
+ last_response.raise_for_status()
162
+ return last_response
163
+
164
+
165
+ def is_rate_limited(exc: BaseException) -> bool:
166
+ if isinstance(exc, RateLimitedError):
167
+ return True
168
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
169
+ return True
170
+ return False
171
+
172
+
173
+ def retry_after_from_exception(exc: BaseException) -> Optional[float]:
174
+ if isinstance(exc, RateLimitedError):
175
+ return exc.retry_after_seconds
176
+ if isinstance(exc, httpx.HTTPStatusError):
177
+ return retry_after_seconds(exc.response)
178
+ return None
@@ -0,0 +1,428 @@
1
+ """Shared httpx tracing for every external HTTP call the CLI makes.
2
+
3
+ Tracing is **off by default** in production. Set ``BOOKING_AGENT_HTTP_TRACE=1`` to
4
+ append redacted request/response records to ``~/.booking-agent/http_trace.log``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Optional
16
+
17
+ import httpx
18
+
19
+ from . import settings
20
+ from .circuit_breaker import HostCircuitBreaker, default_breaker
21
+ from .http_deadlines import coerce_timeout
22
+ from .observability import HEADER, current, emit_event
23
+ from .secure_file import FILE_MODE, ensure_private_dir
24
+
25
+ _REDACT_HEADERS = {"authorization", "dpop", "dpop-nonce", "set-cookie", "cookie"}
26
+ _REDACT_BODY_KEYS = frozenset({
27
+ "token",
28
+ "credential",
29
+ "shared_payment_token",
30
+ "client_secret",
31
+ "access_token",
32
+ "refresh_token",
33
+ "email",
34
+ "phone",
35
+ "phone_number",
36
+ "first_name",
37
+ "last_name",
38
+ "name",
39
+ "notes",
40
+ })
41
+ # Keys are compared lowercased with "_" and "-" removed, so camelCase aliases match too.
42
+ _ADDRESS_LEAF_KEYS = frozenset({
43
+ "line1",
44
+ "line2",
45
+ "addressline",
46
+ "addressline1",
47
+ "addressline2",
48
+ "street",
49
+ "streetaddress",
50
+ "postalcode",
51
+ "postcode",
52
+ "zip",
53
+ "zipcode",
54
+ })
55
+ # Too generic to redact everywhere (business location, booking state), so only under a buyer.
56
+ _BUYER_ADDRESS_KEYS = frozenset({
57
+ "city",
58
+ "locality",
59
+ "region",
60
+ "subdivision",
61
+ "state",
62
+ "administrativearea",
63
+ "country",
64
+ "countrycode",
65
+ })
66
+ _RAW_ADDRESS_LEAF = re.compile(
67
+ r'"((?:address_?)?line_?[12]?|street\w*|postal_?code|post_?code|zip(?:_?code)?'
68
+ r'|city|locality|region|subdivision|state|country(?:_?code)?)"\s*:\s*"[^"]*"',
69
+ re.IGNORECASE,
70
+ )
71
+ _RAW_ADDRESS_OBJECT = re.compile(r'"(\w*address)"\s*:\s*\{[^{}]*\}', re.IGNORECASE)
72
+
73
+
74
+ def tracing_enabled() -> bool:
75
+ return settings.get_bool("BOOKING_AGENT_HTTP_TRACE")
76
+
77
+
78
+ def trace_path() -> Path:
79
+ """Location of the trace log; follows ``BOOKING_AGENT_SESSION_DIR`` like the session store."""
80
+ base = settings.booking_agent_home()
81
+ ensure_private_dir(base)
82
+ return base / "http_trace.log"
83
+
84
+
85
+ _MAX_TRACE_BYTES = 5 * 1024 * 1024
86
+
87
+
88
+ def _redact(headers: httpx.Headers) -> Dict[str, str]:
89
+ return {k: ("<redacted>" if k.lower() in _REDACT_HEADERS else v) for k, v in headers.items()}
90
+
91
+
92
+ def _redact_all_leaves(value: Any) -> Any:
93
+ if isinstance(value, dict):
94
+ return {key: _redact_all_leaves(item) for key, item in value.items()}
95
+ if isinstance(value, list):
96
+ return [_redact_all_leaves(item) for item in value]
97
+ return None if value is None else "<redacted>"
98
+
99
+
100
+ def _redact_json_value(value: Any, *, in_buyer: bool = False) -> Any:
101
+ if isinstance(value, dict):
102
+ out: Dict[str, Any] = {}
103
+ for key, item in value.items():
104
+ key_l = str(key).lower()
105
+ key_n = key_l.replace("_", "").replace("-", "")
106
+ if key_l in _REDACT_BODY_KEYS or "credential" in key_l or key_l.endswith("_token"):
107
+ out[key] = "<redacted>"
108
+ elif key_n.endswith("address"):
109
+ out[key] = _redact_all_leaves(item)
110
+ elif key_n in _ADDRESS_LEAF_KEYS or (in_buyer and key_n in _BUYER_ADDRESS_KEYS):
111
+ out[key] = _redact_all_leaves(item)
112
+ elif key_l == "payment" and isinstance(item, dict):
113
+ out[key] = _redact_json_value(item)
114
+ elif key_l == "instruments" and isinstance(item, list):
115
+ out[key] = [_redact_json_value(i) for i in item]
116
+ elif key_l == "buyer" and isinstance(item, dict):
117
+ out[key] = _redact_json_value(item, in_buyer=True)
118
+ else:
119
+ out[key] = _redact_json_value(item, in_buyer=in_buyer)
120
+ return out
121
+ if isinstance(value, list):
122
+ return [_redact_json_value(item, in_buyer=in_buyer) for item in value]
123
+ return value
124
+
125
+
126
+ def _redact_body_text(body: str | None) -> str | None:
127
+ if not body:
128
+ return body
129
+ try:
130
+ parsed = json.loads(body)
131
+ except json.JSONDecodeError:
132
+ redacted = body
133
+ for pattern in (
134
+ r'"credential"\s*:\s*"[^"]*"',
135
+ r'"token"\s*:\s*"[^"]*"',
136
+ r'"shared_payment_token"\s*:\s*"[^"]*"',
137
+ r'"email"\s*:\s*"[^"]*"',
138
+ r'"phone(?:_number)?"\s*:\s*"[^"]*"',
139
+ r'"notes"\s*:\s*"[^"]*"',
140
+ ):
141
+ redacted = re.sub(pattern, '"<redacted>"', redacted)
142
+ redacted = _RAW_ADDRESS_OBJECT.sub(r'"\1": "<redacted>"', redacted)
143
+ redacted = _RAW_ADDRESS_LEAF.sub(r'"\1": "<redacted>"', redacted)
144
+ return redacted
145
+ return json.dumps(_redact_json_value(parsed), default=str)
146
+
147
+
148
+ def _rotate_trace_if_needed(path: Path) -> None:
149
+ try:
150
+ if path.is_file() and path.stat().st_size > _MAX_TRACE_BYTES:
151
+ backup = path.with_suffix(".log.1")
152
+ if backup.is_file():
153
+ backup.unlink()
154
+ path.replace(backup)
155
+ except OSError:
156
+ pass
157
+
158
+
159
+ def _append(record: Dict[str, Any]) -> None:
160
+ if not tracing_enabled():
161
+ return
162
+ path = trace_path()
163
+ _rotate_trace_if_needed(path)
164
+ fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, FILE_MODE)
165
+ with os.fdopen(fd, "a") as f:
166
+ f.write(json.dumps(record, default=str) + "\n")
167
+
168
+
169
+ def _request_body_for_trace(request: httpx.Request) -> str | None:
170
+ try:
171
+ content = request.content
172
+ except httpx.RequestNotRead:
173
+ return None
174
+ if not content:
175
+ return None
176
+ return _redact_body_text(content.decode("utf-8", errors="replace"))
177
+
178
+
179
+ async def _stamp_trace_t0(request: httpx.Request) -> None:
180
+ request.extensions["trace_t0"] = time.monotonic()
181
+
182
+
183
+ async def _record_step_http_elapsed(response: httpx.Response) -> None:
184
+ t0 = response.request.extensions.get("trace_t0")
185
+ if t0 is None:
186
+ return
187
+ from .step_timing import record_http_elapsed_ms
188
+
189
+ record_http_elapsed_ms(round((time.monotonic() - t0) * 1000, 1))
190
+
191
+
192
+ # Last failing (>= 400) exchange, for the consented support report (spec #307).
193
+ # Module-level rather than a ContextVar: hooks run inside ``asyncio.run``, whose
194
+ # context copy never flows back to the synchronous caller.
195
+ _last_http_failure: Optional[Dict[str, Any]] = None
196
+ _FAILURE_BODY_MAX_BYTES = 64 * 1024
197
+ _TOKEN_RE = re.compile(r"^[a-z0-9_.]{1,64}$")
198
+ _PROBLEM_TOKEN_RE = re.compile(r"^[A-Za-z0-9_.:/-]{1,128}$")
199
+ _MEDIA_TYPE_RE = re.compile(r"^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$")
200
+ _ID_SEGMENT_RE = re.compile(
201
+ r"^(?:[A-Za-z0-9]+[_-])*[A-Za-z0-9]*\d[A-Za-z0-9_-]*$|^[0-9a-fA-F-]{16,}$"
202
+ )
203
+ _SAFE_SEGMENT_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,40}$")
204
+
205
+
206
+ def last_http_failure() -> Optional[Dict[str, Any]]:
207
+ """The last failing exchange recorded in this process, or None."""
208
+ return dict(_last_http_failure) if _last_http_failure else None
209
+
210
+
211
+ def clear_last_http_failure() -> None:
212
+ global _last_http_failure
213
+ _last_http_failure = None
214
+
215
+
216
+ def _template_path(path: str) -> str:
217
+ out = []
218
+ for segment in path.split("/"):
219
+ if not segment or _SAFE_SEGMENT_RE.match(segment) or _ID_SEGMENT_RE.match(segment):
220
+ out.append(segment)
221
+ else:
222
+ out.append("{x}")
223
+ return "/".join(out)[:512]
224
+
225
+
226
+ def _safe_problem_type(value: Any) -> Optional[str]:
227
+ if not isinstance(value, str) or not value or len(value) > 256:
228
+ return None
229
+ if value.startswith("https://"):
230
+ from urllib.parse import urlsplit
231
+
232
+ parts = urlsplit(value)
233
+ if parts.username or parts.password or parts.query or parts.fragment:
234
+ return None
235
+ if not _PROBLEM_TOKEN_RE.match(parts.path or "/") and parts.path:
236
+ return None
237
+ return value
238
+ return value if _PROBLEM_TOKEN_RE.match(value) and "/" not in value else None
239
+
240
+
241
+ def _failure_body_json(response: httpx.Response) -> Any:
242
+ media = (response.headers.get("content-type") or "").split(";")[0].strip().lower()
243
+ if not (media == "application/json" or media.endswith("+json")):
244
+ return None
245
+ try:
246
+ content = response.content
247
+ except httpx.ResponseNotRead:
248
+ return None
249
+ if not content or len(content) > _FAILURE_BODY_MAX_BYTES:
250
+ return None
251
+ return json.loads(content.decode("utf-8"))
252
+
253
+
254
+ def _ucp_message_codes(body: Any) -> tuple[list, int]:
255
+ messages = body.get("messages") if isinstance(body, dict) else None
256
+ if not isinstance(messages, list):
257
+ return [], 0
258
+ codes, dropped = [], 0
259
+ for item in messages:
260
+ code = item.get("code") if isinstance(item, dict) else None
261
+ if not isinstance(code, str) or not _TOKEN_RE.match(code):
262
+ dropped += 1
263
+ continue
264
+ severity = item.get("severity")
265
+ codes.append({
266
+ "code": code,
267
+ "severity": severity if isinstance(severity, str) and _TOKEN_RE.match(severity) else None,
268
+ })
269
+ return codes, dropped
270
+
271
+
272
+ async def _record_http_failure(response: httpx.Response) -> None:
273
+ global _last_http_failure
274
+ try:
275
+ if response.status_code < 400:
276
+ return
277
+ request = response.request
278
+ record: Dict[str, Any] = {
279
+ "method": request.method,
280
+ "host": request.url.host or "",
281
+ "path": _template_path(request.url.path or "/"),
282
+ "status": response.status_code,
283
+ "content_type": None,
284
+ "problem_type": None,
285
+ "ucp_message_codes": [],
286
+ "dropped_codes": 0,
287
+ "elapsed_ms": None,
288
+ }
289
+ _last_http_failure = record
290
+ media = (response.headers.get("content-type") or "").split(";")[0].strip().lower()
291
+ if media and len(media) <= 128 and _MEDIA_TYPE_RE.match(media):
292
+ record["content_type"] = media
293
+ t0 = request.extensions.get("trace_t0")
294
+ if t0 is not None:
295
+ record["elapsed_ms"] = round((time.monotonic() - t0) * 1000, 1)
296
+ declared = response.headers.get("content-length")
297
+ if declared is not None and declared.isdigit() and int(declared) <= _FAILURE_BODY_MAX_BYTES:
298
+ await response.aread()
299
+ body = _failure_body_json(response)
300
+ if isinstance(body, dict):
301
+ record["problem_type"] = _safe_problem_type(body.get("type"))
302
+ nested = body.get("checkout") if isinstance(body.get("checkout"), dict) else body
303
+ codes, dropped = _ucp_message_codes(nested)
304
+ record["ucp_message_codes"] = codes[:20]
305
+ record["dropped_codes"] = dropped + max(len(codes) - 20, 0)
306
+ except Exception:
307
+ pass
308
+
309
+
310
+ async def _inject_correlation_header(request: httpx.Request) -> None:
311
+ cid = current()
312
+ if cid:
313
+ request.headers[HEADER] = cid
314
+
315
+
316
+ async def _log_request(request: httpx.Request) -> None:
317
+ if not tracing_enabled():
318
+ return
319
+ trace_id = str(uuid.uuid4())
320
+ request.extensions["trace_id"] = trace_id
321
+ request.extensions["trace_t0"] = time.monotonic()
322
+ body = _request_body_for_trace(request)
323
+ record: Dict[str, Any] = {
324
+ "trace_id": trace_id,
325
+ "direction": "request",
326
+ "method": request.method,
327
+ "url": str(request.url),
328
+ "headers": _redact(request.headers),
329
+ "body": body,
330
+ }
331
+ cid = current()
332
+ if cid:
333
+ record["correlation_id"] = cid
334
+ _append(record)
335
+
336
+
337
+ async def _log_response(response: httpx.Response) -> None:
338
+ if not tracing_enabled():
339
+ return
340
+ await response.aread()
341
+ t0 = response.request.extensions.get("trace_t0")
342
+ elapsed_ms = round((time.monotonic() - t0) * 1000, 1) if t0 else None
343
+ record: Dict[str, Any] = {
344
+ "trace_id": response.request.extensions.get("trace_id"),
345
+ "direction": "response",
346
+ "status_code": response.status_code,
347
+ "url": str(response.request.url),
348
+ "headers": _redact(response.headers),
349
+ "body": _redact_body_text(response.text),
350
+ "elapsed_ms": elapsed_ms,
351
+ }
352
+ cid = current()
353
+ if cid:
354
+ record["correlation_id"] = cid
355
+ _append(record)
356
+
357
+
358
+ class _CircuitTransport(httpx.AsyncBaseTransport):
359
+ """Records timeouts and 5xx/429 against the per-host circuit breaker."""
360
+
361
+ def __init__(
362
+ self, inner: httpx.AsyncBaseTransport, breaker: HostCircuitBreaker
363
+ ) -> None:
364
+ self._inner = inner
365
+ self._breaker = breaker
366
+
367
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
368
+ self._breaker.guard(request.url)
369
+ try:
370
+ response = await self._inner.handle_async_request(request)
371
+ except httpx.TimeoutException as exc:
372
+ kind = "connect" if isinstance(exc, httpx.ConnectTimeout) else (
373
+ "read" if isinstance(exc, httpx.ReadTimeout) else "other"
374
+ )
375
+ emit_event("http_timeout", kind=kind, host=request.url.host or "")
376
+ self._breaker.record_failure(request.url)
377
+ raise
378
+ except httpx.NetworkError:
379
+ self._breaker.record_failure(request.url)
380
+ raise
381
+ if response.status_code >= 500 or response.status_code == 429:
382
+ self._breaker.record_failure(request.url)
383
+ else:
384
+ self._breaker.record_success(request.url)
385
+ return response
386
+
387
+ async def aclose(self) -> None:
388
+ await self._inner.aclose()
389
+
390
+
391
+ def traced_async_client(
392
+ **kwargs: Any,
393
+ ) -> httpx.AsyncClient:
394
+ """``httpx.AsyncClient`` with optional redacted trace logging."""
395
+ circuit: Optional[HostCircuitBreaker] = kwargs.pop("circuit", None)
396
+ if circuit is None:
397
+ circuit = default_breaker()
398
+ kwargs["timeout"] = coerce_timeout(kwargs.get("timeout"))
399
+ inner = kwargs.pop("transport", None)
400
+ if inner is None:
401
+ inner = httpx.AsyncHTTPTransport()
402
+ kwargs["transport"] = _CircuitTransport(inner, circuit)
403
+
404
+ hooks = dict(kwargs.pop("event_hooks", {}) or {})
405
+ hooks["request"] = [
406
+ *hooks.get("request", []),
407
+ _stamp_trace_t0,
408
+ _inject_correlation_header,
409
+ ]
410
+ hooks["response"] = [
411
+ *hooks.get("response", []), _record_step_http_elapsed, _record_http_failure,
412
+ ]
413
+ if tracing_enabled():
414
+ hooks["request"] = [*hooks["request"], _log_request]
415
+ hooks["response"] = [*hooks["response"], _log_response]
416
+ return httpx.AsyncClient(event_hooks=hooks, **kwargs)
417
+
418
+
419
+ def purge_trace_log() -> bool:
420
+ """Delete the trace log if present (used by sessions forget / hygiene)."""
421
+ path = trace_path()
422
+ try:
423
+ if path.is_file():
424
+ path.unlink()
425
+ return True
426
+ except OSError:
427
+ pass
428
+ return False
@@ -0,0 +1,73 @@
1
+ """Configurable delay between merchant-host HTTP calls to avoid burst rate limits."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import time
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass, field
8
+ from typing import Dict, Tuple
9
+ from urllib.parse import urlparse
10
+
11
+ from . import settings
12
+
13
+
14
+ def merchant_request_delay_seconds() -> float:
15
+ return settings.get_int("BOOKING_AGENT_MERCHANT_REQUEST_DELAY_MS", minimum=0) / 1000.0
16
+
17
+
18
+ def discovery_concurrency() -> int:
19
+ return settings.get_int("BOOKING_AGENT_DISCOVERY_CONCURRENCY", minimum=1)
20
+
21
+
22
+ def merchant_host_concurrency() -> int:
23
+ return settings.get_int("BOOKING_AGENT_MERCHANT_HOST_CONCURRENCY", minimum=1)
24
+
25
+
26
+ def merchant_host_from_url(url: str) -> str:
27
+ return urlparse(url).netloc.lower()
28
+
29
+
30
+ @dataclass
31
+ class _HostState:
32
+ slots: asyncio.Semaphore
33
+ start_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
34
+ last_start_monotonic: float = 0.0
35
+
36
+
37
+ # Keyed by event loop as well as host: asyncio primitives belong to one loop.
38
+ _host_states: Dict[Tuple[int, str], _HostState] = {}
39
+
40
+
41
+ def _host_state(host: str) -> _HostState:
42
+ key = (id(asyncio.get_running_loop()), host)
43
+ state = _host_states.get(key)
44
+ if state is None:
45
+ state = _HostState(slots=asyncio.Semaphore(merchant_host_concurrency()))
46
+ _host_states[key] = state
47
+ return state
48
+
49
+
50
+ @asynccontextmanager
51
+ async def paced_merchant_host_request(host: str):
52
+ """Space request starts to one host by the configured delay, with a per-host cap
53
+ on requests in flight. Requests overlap; only their starts are paced."""
54
+ state = _host_state(host)
55
+ async with state.slots:
56
+ async with state.start_lock:
57
+ delay = merchant_request_delay_seconds()
58
+ if delay > 0 and state.last_start_monotonic > 0:
59
+ wait_until = state.last_start_monotonic + delay
60
+ now = time.monotonic()
61
+ if now < wait_until:
62
+ sleep_for = wait_until - now
63
+ await asyncio.sleep(sleep_for)
64
+ from booking_agent.step_timing import record_pacing_ms
65
+
66
+ record_pacing_ms(sleep_for * 1000.0)
67
+ state.last_start_monotonic = time.monotonic()
68
+ yield
69
+
70
+
71
+ def reset_merchant_pacing_for_tests() -> None:
72
+ """Clear per-host pacing state (tests only)."""
73
+ _host_states.clear()
@@ -0,0 +1,14 @@
1
+ """Re-exports for convenience: wire (USP spec) + platform (Booking Agent CLI) models."""
2
+ from .wire import * # noqa: F401,F403
3
+ from .platform import ( # noqa: F401
4
+ BookingPaymentMode,
5
+ ServiceSearchLocation,
6
+ RangeMatchMode,
7
+ PriceRange,
8
+ DurationRange,
9
+ ServiceSearchIntent,
10
+ ServiceSearchResult,
11
+ ServiceSearchResultPrice,
12
+ ServiceSearchResultLocation,
13
+ ServiceSearchPagination,
14
+ )