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.
- booking_agent/__init__.py +6 -0
- booking_agent/authorization.py +450 -0
- booking_agent/backend/__init__.py +217 -0
- booking_agent/backend/live.py +494 -0
- booking_agent/calendar/__init__.py +55 -0
- booking_agent/calendar/filter.py +41 -0
- booking_agent/calendar/google.py +582 -0
- booking_agent/calendar/oauth_listener.py +287 -0
- booking_agent/calendar/scope.py +97 -0
- booking_agent/circuit_breaker.py +116 -0
- booking_agent/config.py +51 -0
- booking_agent/defaults.env +50 -0
- booking_agent/discovery.py +188 -0
- booking_agent/headless.py +463 -0
- booking_agent/http_deadlines.py +43 -0
- booking_agent/http_retry.py +178 -0
- booking_agent/http_trace.py +428 -0
- booking_agent/merchant_pacing.py +73 -0
- booking_agent/models/__init__.py +14 -0
- booking_agent/models/platform.py +200 -0
- booking_agent/models/wire.py +868 -0
- booking_agent/namespace.py +53 -0
- booking_agent/observability.py +143 -0
- booking_agent/platform_profile.py +120 -0
- booking_agent/pop.py +347 -0
- booking_agent/profile_cache.py +130 -0
- booking_agent/registry_url.py +35 -0
- booking_agent/rest_client.py +1493 -0
- booking_agent/secure_file.py +52 -0
- booking_agent/settings.py +110 -0
- booking_agent/signals.py +130 -0
- booking_agent/step_timing.py +168 -0
- booking_agent/ucp_capabilities.py +148 -0
- booking_agent/ucp_checkout.py +1363 -0
- booking_agent/ucp_negotiation.py +184 -0
- booking_agent/ucp_profile.py +410 -0
- booking_agent/usp_methods.py +110 -0
- booking_agent_cli/__init__.py +3 -0
- booking_agent_cli/activity_log.py +106 -0
- booking_agent_cli/booking_poll.py +141 -0
- booking_agent_cli/booking_summary.py +151 -0
- booking_agent_cli/calendar_connection.py +196 -0
- booking_agent_cli/calendar_event.py +611 -0
- booking_agent_cli/calendar_preference.py +176 -0
- booking_agent_cli/catalog.py +864 -0
- booking_agent_cli/checkout_prereq.py +67 -0
- booking_agent_cli/commands/__init__.py +0 -0
- booking_agent_cli/commands/config.py +106 -0
- booking_agent_cli/commands/flow.py +6988 -0
- booking_agent_cli/commands/lowlevel.py +163 -0
- booking_agent_cli/commands/runtime.py +46 -0
- booking_agent_cli/config_loader.py +108 -0
- booking_agent_cli/engine.py +168 -0
- booking_agent_cli/flow_timings.py +34 -0
- booking_agent_cli/geo_eligibility.py +183 -0
- booking_agent_cli/geocode.py +233 -0
- booking_agent_cli/identity.py +104 -0
- booking_agent_cli/link_cli.py +620 -0
- booking_agent_cli/main.py +75 -0
- booking_agent_cli/output.py +199 -0
- booking_agent_cli/payment_helpers.py +884 -0
- booking_agent_cli/payment_token.py +23 -0
- booking_agent_cli/reschedule.py +137 -0
- booking_agent_cli/runtime.py +55 -0
- booking_agent_cli/runtime_prepare.py +129 -0
- booking_agent_cli/search_area.py +111 -0
- booking_agent_cli/session.py +837 -0
- booking_agent_cli/support_bundle_schema.py +557 -0
- booking_agent_cli/support_report.py +1210 -0
- booking_agent_cli/timezone_context.py +314 -0
- booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
- booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
- booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|