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,287 @@
|
|
|
1
|
+
"""Out-of-process Google OAuth callback listener.
|
|
2
|
+
|
|
3
|
+
``start`` binds the callback port in the calling process, hands the listening
|
|
4
|
+
socket to a detached child, records the child, and returns. The calling command
|
|
5
|
+
therefore exits right away, and a harness that kills it cannot take the listener
|
|
6
|
+
down with it or leave the port bound: the child stops on its own at its deadline.
|
|
7
|
+
|
|
8
|
+
``status`` and ``cleanup`` read and clear the record, so a later command can
|
|
9
|
+
report the result or release a listener that is no longer wanted.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
from booking_agent.secure_file import ensure_private_dir, write_private_text
|
|
25
|
+
|
|
26
|
+
STATUS_PENDING = "pending"
|
|
27
|
+
STATUS_CONNECTED = "connected"
|
|
28
|
+
STATUS_TIMEOUT = "timeout"
|
|
29
|
+
STATUS_FAILED = "failed"
|
|
30
|
+
STATUS_NONE = "none"
|
|
31
|
+
|
|
32
|
+
# A child is only signalled while it can still be ours: past this grace the pid may
|
|
33
|
+
# belong to an unrelated process, and the child has already exited on its own.
|
|
34
|
+
_KILL_GRACE_SECONDS = 30
|
|
35
|
+
_BIND_HOST = "0.0.0.0"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CallbackPortBusy(RuntimeError):
|
|
39
|
+
"""The OAuth redirect port is held by another process."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, port: int):
|
|
42
|
+
super().__init__(f"Port {port} is already in use, so the sign-in callback cannot start.")
|
|
43
|
+
self.port = port
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _state_dir() -> Path:
|
|
47
|
+
from booking_agent.settings import booking_agent_home
|
|
48
|
+
|
|
49
|
+
return booking_agent_home() / ".calendar-oauth"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _record_path() -> Path:
|
|
53
|
+
return _state_dir() / "listener.json"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _result_path() -> Path:
|
|
57
|
+
return _state_dir() / "result.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_json(path: Path) -> Optional[Dict[str, Any]]:
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
return None
|
|
65
|
+
return data if isinstance(data, dict) else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _remove(path: Path) -> None:
|
|
69
|
+
try:
|
|
70
|
+
path.unlink()
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
pass
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pid_alive(pid: int) -> bool:
|
|
78
|
+
if pid <= 0:
|
|
79
|
+
return False
|
|
80
|
+
if os.name == "nt":
|
|
81
|
+
out = subprocess.run(
|
|
82
|
+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
83
|
+
capture_output=True, text=True, check=False,
|
|
84
|
+
).stdout
|
|
85
|
+
return str(pid) in out
|
|
86
|
+
try:
|
|
87
|
+
os.kill(pid, 0)
|
|
88
|
+
except ProcessLookupError:
|
|
89
|
+
return False
|
|
90
|
+
except PermissionError:
|
|
91
|
+
return True
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _terminate(pid: int) -> None:
|
|
96
|
+
if os.name == "nt":
|
|
97
|
+
subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, check=False)
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
os.kill(pid, signal.SIGTERM)
|
|
101
|
+
except OSError:
|
|
102
|
+
return
|
|
103
|
+
for _ in range(20):
|
|
104
|
+
if not pid_alive(pid):
|
|
105
|
+
return
|
|
106
|
+
time.sleep(0.1)
|
|
107
|
+
try:
|
|
108
|
+
os.kill(pid, signal.SIGKILL)
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _record_owns_live_child(record: Dict[str, Any]) -> bool:
|
|
114
|
+
pid = int(record.get("pid") or 0)
|
|
115
|
+
deadline = float(record.get("deadline") or 0)
|
|
116
|
+
return time.time() < deadline + _KILL_GRACE_SECONDS and pid_alive(pid)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cleanup() -> bool:
|
|
120
|
+
"""Stop a recorded listener and forget it. True when a running child was stopped."""
|
|
121
|
+
record = _read_json(_record_path())
|
|
122
|
+
stopped = False
|
|
123
|
+
if record and _record_owns_live_child(record):
|
|
124
|
+
_terminate(int(record["pid"]))
|
|
125
|
+
stopped = True
|
|
126
|
+
_remove(_record_path())
|
|
127
|
+
_remove(_result_path())
|
|
128
|
+
return stopped
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _bind(port: int) -> socket.socket:
|
|
132
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
133
|
+
if os.name != "nt":
|
|
134
|
+
# Only lets a port in TIME_WAIT be reused; a live listener still refuses the bind.
|
|
135
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
136
|
+
try:
|
|
137
|
+
sock.bind((_BIND_HOST, port))
|
|
138
|
+
sock.listen(16)
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
sock.close()
|
|
141
|
+
raise CallbackPortBusy(port) from exc
|
|
142
|
+
return sock
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def start(
|
|
146
|
+
*,
|
|
147
|
+
port: int,
|
|
148
|
+
state: str,
|
|
149
|
+
pkce: Dict[str, str],
|
|
150
|
+
mode: str,
|
|
151
|
+
timeout: int,
|
|
152
|
+
) -> Dict[str, Any]:
|
|
153
|
+
"""Start the callback child for one sign-in attempt and return its record."""
|
|
154
|
+
cleanup()
|
|
155
|
+
ensure_private_dir(_state_dir())
|
|
156
|
+
sock = _bind(port)
|
|
157
|
+
deadline = time.time() + timeout
|
|
158
|
+
payload = {
|
|
159
|
+
"state": state,
|
|
160
|
+
"pkce": pkce,
|
|
161
|
+
"deadline": deadline,
|
|
162
|
+
"port": port,
|
|
163
|
+
"result_path": str(_result_path()),
|
|
164
|
+
}
|
|
165
|
+
kwargs: Dict[str, Any] = {
|
|
166
|
+
"stdin": subprocess.PIPE,
|
|
167
|
+
"stdout": subprocess.DEVNULL,
|
|
168
|
+
"stderr": subprocess.DEVNULL,
|
|
169
|
+
"env": dict(os.environ),
|
|
170
|
+
}
|
|
171
|
+
if os.name == "nt":
|
|
172
|
+
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
|
|
173
|
+
subprocess, "CREATE_NEW_PROCESS_GROUP", 0,
|
|
174
|
+
)
|
|
175
|
+
sock.close()
|
|
176
|
+
else:
|
|
177
|
+
sock.set_inheritable(True)
|
|
178
|
+
payload["fd"] = sock.fileno()
|
|
179
|
+
kwargs["pass_fds"] = (sock.fileno(),)
|
|
180
|
+
kwargs["start_new_session"] = True
|
|
181
|
+
try:
|
|
182
|
+
child = subprocess.Popen(
|
|
183
|
+
[sys.executable, "-m", "booking_agent.calendar.oauth_listener"], **kwargs,
|
|
184
|
+
)
|
|
185
|
+
assert child.stdin is not None
|
|
186
|
+
child.stdin.write(json.dumps(payload).encode("utf-8"))
|
|
187
|
+
child.stdin.close()
|
|
188
|
+
finally:
|
|
189
|
+
if os.name != "nt":
|
|
190
|
+
sock.close()
|
|
191
|
+
record = {
|
|
192
|
+
"pid": child.pid,
|
|
193
|
+
"port": port,
|
|
194
|
+
"mode": mode,
|
|
195
|
+
"state": state,
|
|
196
|
+
"deadline": deadline,
|
|
197
|
+
"started_at": time.time(),
|
|
198
|
+
}
|
|
199
|
+
write_private_text(_record_path(), json.dumps(record))
|
|
200
|
+
return record
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def status() -> Dict[str, Any]:
|
|
204
|
+
"""Current sign-in attempt: ``pending``, ``connected``, ``timeout``, ``failed``, or ``none``."""
|
|
205
|
+
record = _read_json(_record_path())
|
|
206
|
+
if not record:
|
|
207
|
+
return {"status": STATUS_NONE}
|
|
208
|
+
out = {"status": STATUS_PENDING, "mode": record.get("mode"), "deadline": record.get("deadline")}
|
|
209
|
+
result = _read_json(_result_path())
|
|
210
|
+
if result and result.get("state") == record.get("state"):
|
|
211
|
+
out["status"] = result.get("status") or STATUS_FAILED
|
|
212
|
+
if result.get("error"):
|
|
213
|
+
out["error"] = result["error"]
|
|
214
|
+
return out
|
|
215
|
+
if time.time() >= float(record.get("deadline") or 0):
|
|
216
|
+
out["status"] = STATUS_TIMEOUT
|
|
217
|
+
return out
|
|
218
|
+
if not pid_alive(int(record.get("pid") or 0)):
|
|
219
|
+
out["status"] = STATUS_FAILED
|
|
220
|
+
out["error"] = "The sign-in listener stopped before Google Calendar was connected."
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Child process
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _write_result(path: str, state: str, status_value: str, error: Optional[str] = None) -> None:
|
|
230
|
+
body: Dict[str, Any] = {"state": state, "status": status_value}
|
|
231
|
+
if error:
|
|
232
|
+
body["error"] = error
|
|
233
|
+
write_private_text(Path(path), json.dumps(body))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def _serve(payload: Dict[str, Any]) -> None:
|
|
237
|
+
import uvicorn
|
|
238
|
+
|
|
239
|
+
from booking_agent.calendar import google
|
|
240
|
+
|
|
241
|
+
state = payload["state"]
|
|
242
|
+
google._PKCE_STORE[state] = dict(payload["pkce"])
|
|
243
|
+
if "fd" in payload:
|
|
244
|
+
config = uvicorn.Config(google._build_callback_app(), fd=int(payload["fd"]), log_level="warning")
|
|
245
|
+
else:
|
|
246
|
+
config = uvicorn.Config(
|
|
247
|
+
google._build_callback_app(), host=_BIND_HOST, port=int(payload["port"]), log_level="warning",
|
|
248
|
+
)
|
|
249
|
+
server = uvicorn.Server(config)
|
|
250
|
+
result_path = payload["result_path"]
|
|
251
|
+
deadline = float(payload["deadline"])
|
|
252
|
+
|
|
253
|
+
async def _watch() -> None:
|
|
254
|
+
while not server.should_exit:
|
|
255
|
+
if state in google._TOKEN_STORE:
|
|
256
|
+
_write_result(result_path, state, STATUS_CONNECTED)
|
|
257
|
+
# Let the success page reach the browser before the listener stops.
|
|
258
|
+
await asyncio.sleep(1.5)
|
|
259
|
+
server.should_exit = True
|
|
260
|
+
return
|
|
261
|
+
if google._OAUTH_FAILURES.get(state):
|
|
262
|
+
_write_result(result_path, state, STATUS_FAILED, google._OAUTH_FAILURES[state])
|
|
263
|
+
await asyncio.sleep(1.5)
|
|
264
|
+
server.should_exit = True
|
|
265
|
+
return
|
|
266
|
+
if time.time() >= deadline:
|
|
267
|
+
_write_result(result_path, state, STATUS_TIMEOUT)
|
|
268
|
+
server.should_exit = True
|
|
269
|
+
return
|
|
270
|
+
await asyncio.sleep(0.5)
|
|
271
|
+
|
|
272
|
+
watcher = asyncio.create_task(_watch())
|
|
273
|
+
try:
|
|
274
|
+
await server.serve()
|
|
275
|
+
except (OSError, SystemExit) as exc:
|
|
276
|
+
_write_result(result_path, state, STATUS_FAILED, f"The sign-in listener could not start ({type(exc).__name__}).")
|
|
277
|
+
finally:
|
|
278
|
+
watcher.cancel()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def main() -> None:
|
|
282
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
283
|
+
asyncio.run(_serve(payload))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
main()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Google Calendar OAuth scope helpers for the Booking Agent CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from booking_agent.http_deadlines import coerce_timeout
|
|
9
|
+
|
|
10
|
+
SCOPE_FREEBUSY = "https://www.googleapis.com/auth/calendar.freebusy"
|
|
11
|
+
SCOPE_EVENTS = "https://www.googleapis.com/auth/calendar.events"
|
|
12
|
+
SCOPE_FULL = "https://www.googleapis.com/auth/calendar"
|
|
13
|
+
|
|
14
|
+
CALENDAR_MODE_NONE = "none"
|
|
15
|
+
CALENDAR_MODE_FREEBUSY = "freebusy"
|
|
16
|
+
CALENDAR_MODE_EVENTS = "events"
|
|
17
|
+
|
|
18
|
+
TOKENINFO_URL = "https://www.googleapis.com/oauth2/v3/tokeninfo"
|
|
19
|
+
|
|
20
|
+
TOKENINFO_ERROR_INVALID = "invalid_token"
|
|
21
|
+
TOKENINFO_ERROR_HTTP = "tokeninfo_http_error"
|
|
22
|
+
TOKENINFO_ERROR_UNAVAILABLE = "tokeninfo_unavailable"
|
|
23
|
+
|
|
24
|
+
_WRITE_SCOPE_EXACT = {SCOPE_EVENTS, SCOPE_FULL}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def oauth_scope_param(mode: str) -> str:
|
|
28
|
+
"""Space-delimited scope string for Google OAuth consent."""
|
|
29
|
+
if mode == CALENDAR_MODE_EVENTS:
|
|
30
|
+
return f"{SCOPE_FREEBUSY} {SCOPE_EVENTS}"
|
|
31
|
+
return SCOPE_FREEBUSY
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scope_string(tokeninfo: Dict[str, Any]) -> str:
|
|
35
|
+
return str(tokeninfo.get("scope") or "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def supports_freebusy(scope: str) -> bool:
|
|
39
|
+
if not scope:
|
|
40
|
+
return False
|
|
41
|
+
parts = scope.split()
|
|
42
|
+
if SCOPE_FULL in parts or SCOPE_FREEBUSY in parts:
|
|
43
|
+
return True
|
|
44
|
+
if supports_events(scope):
|
|
45
|
+
return True
|
|
46
|
+
return "calendar.readonly" in scope or "calendar.freebusy" in scope
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def supports_events(scope: str) -> bool:
|
|
50
|
+
if not scope:
|
|
51
|
+
return False
|
|
52
|
+
parts = scope.split()
|
|
53
|
+
if any(p in _WRITE_SCOPE_EXACT for p in parts):
|
|
54
|
+
return True
|
|
55
|
+
return SCOPE_FULL in parts
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def scope_satisfies_mode(scope: str, mode: str) -> bool:
|
|
59
|
+
if mode == CALENDAR_MODE_EVENTS:
|
|
60
|
+
return supports_freebusy(scope) and supports_events(scope)
|
|
61
|
+
if mode == CALENDAR_MODE_FREEBUSY:
|
|
62
|
+
return supports_freebusy(scope)
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fetch_tokeninfo(access_token: str) -> Dict[str, Any]:
|
|
67
|
+
with httpx.Client(timeout=coerce_timeout(15.0)) as client:
|
|
68
|
+
resp = client.get(TOKENINFO_URL, params={"access_token": access_token})
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
return resp.json()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tokeninfo_error(exc: Exception) -> Dict[str, Any]:
|
|
74
|
+
"""Classify a tokeninfo failure without echoing the request URL or token."""
|
|
75
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
76
|
+
status = exc.response.status_code
|
|
77
|
+
code = TOKENINFO_ERROR_INVALID if status in (400, 401) else TOKENINFO_ERROR_HTTP
|
|
78
|
+
return {"error": code, "http_status": status}
|
|
79
|
+
return {"error": TOKENINFO_ERROR_UNAVAILABLE}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def inspect_access_token(access_token: str) -> Tuple[Optional[str], Dict[str, Any]]:
|
|
83
|
+
"""Return (scope_string, tokeninfo) or (None, sanitized_error_dict) on failure."""
|
|
84
|
+
try:
|
|
85
|
+
info = fetch_tokeninfo(access_token)
|
|
86
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
87
|
+
return None, tokeninfo_error(e)
|
|
88
|
+
if not isinstance(info, dict):
|
|
89
|
+
return None, {"error": TOKENINFO_ERROR_UNAVAILABLE}
|
|
90
|
+
return scope_string(info), info
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def connection_capabilities(scope: str) -> Dict[str, bool]:
|
|
94
|
+
return {
|
|
95
|
+
"supports_freebusy": supports_freebusy(scope),
|
|
96
|
+
"supports_events": supports_events(scope),
|
|
97
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Per-host circuit breaker so a failing merchant or registry is not hammered."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from . import settings
|
|
11
|
+
from booking_agent.observability import emit_event
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CircuitOpenError(RuntimeError):
|
|
15
|
+
"""Raised when the circuit for a host is open and the cooldown has not elapsed."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, host: str, retry_after: float) -> None:
|
|
18
|
+
super().__init__(
|
|
19
|
+
f"circuit open for {host}; retry after {retry_after:.0f}s"
|
|
20
|
+
)
|
|
21
|
+
self.host = host
|
|
22
|
+
self.retry_after = retry_after
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _host_key(url: Union[str, httpx.URL]) -> str:
|
|
26
|
+
parsed = httpx.URL(url) if isinstance(url, str) else url
|
|
27
|
+
host = parsed.host or ""
|
|
28
|
+
return host.lower()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class _Bucket:
|
|
33
|
+
failures: int = 0
|
|
34
|
+
opened_at: Optional[float] = None
|
|
35
|
+
half_open_emitted: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HostCircuitBreaker:
|
|
39
|
+
"""Counts consecutive failures per host and sheds load once the threshold trips.
|
|
40
|
+
|
|
41
|
+
A success closes the circuit. After ``cooldown_seconds`` an open circuit
|
|
42
|
+
becomes half-open: the next call is allowed through; another failure reopens
|
|
43
|
+
it immediately.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
failure_threshold: int = 5,
|
|
50
|
+
cooldown_seconds: float = 30.0,
|
|
51
|
+
disabled: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.failure_threshold = max(1, failure_threshold)
|
|
54
|
+
self.cooldown_seconds = max(0.0, cooldown_seconds)
|
|
55
|
+
self.disabled = disabled
|
|
56
|
+
self._buckets: dict[str, _Bucket] = {}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_env(cls) -> "HostCircuitBreaker":
|
|
60
|
+
return cls(
|
|
61
|
+
failure_threshold=settings.get_int("BOOKING_AGENT_CIRCUIT_FAILURE_THRESHOLD"),
|
|
62
|
+
cooldown_seconds=settings.get_float("BOOKING_AGENT_CIRCUIT_COOLDOWN_SECONDS"),
|
|
63
|
+
disabled=settings.get_bool("BOOKING_AGENT_CIRCUIT_DISABLED"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def guard(self, url: Union[str, httpx.URL]) -> None:
|
|
67
|
+
if self.disabled:
|
|
68
|
+
return
|
|
69
|
+
host = _host_key(url)
|
|
70
|
+
bucket = self._buckets.get(host)
|
|
71
|
+
if bucket is None or bucket.opened_at is None:
|
|
72
|
+
return
|
|
73
|
+
elapsed = time.monotonic() - bucket.opened_at
|
|
74
|
+
if elapsed >= self.cooldown_seconds:
|
|
75
|
+
if not bucket.half_open_emitted:
|
|
76
|
+
bucket.half_open_emitted = True
|
|
77
|
+
emit_event("circuit_half_open", host=host)
|
|
78
|
+
return
|
|
79
|
+
raise CircuitOpenError(host, retry_after=self.cooldown_seconds - elapsed)
|
|
80
|
+
|
|
81
|
+
def record_failure(self, url: Union[str, httpx.URL]) -> None:
|
|
82
|
+
if self.disabled:
|
|
83
|
+
return
|
|
84
|
+
host = _host_key(url)
|
|
85
|
+
bucket = self._buckets.setdefault(host, _Bucket())
|
|
86
|
+
bucket.failures += 1
|
|
87
|
+
if bucket.failures >= self.failure_threshold:
|
|
88
|
+
if bucket.opened_at is None:
|
|
89
|
+
bucket.opened_at = time.monotonic()
|
|
90
|
+
bucket.half_open_emitted = False
|
|
91
|
+
emit_event("circuit_open", host=host)
|
|
92
|
+
elif bucket.half_open_emitted:
|
|
93
|
+
bucket.opened_at = time.monotonic()
|
|
94
|
+
bucket.half_open_emitted = False
|
|
95
|
+
emit_event("circuit_open", host=host)
|
|
96
|
+
|
|
97
|
+
def record_success(self, url: Union[str, httpx.URL]) -> None:
|
|
98
|
+
if self.disabled:
|
|
99
|
+
return
|
|
100
|
+
host = _host_key(url)
|
|
101
|
+
self._buckets[host] = _Bucket()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_default: Optional[HostCircuitBreaker] = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_breaker() -> HostCircuitBreaker:
|
|
108
|
+
global _default
|
|
109
|
+
if _default is None:
|
|
110
|
+
_default = HostCircuitBreaker.from_env()
|
|
111
|
+
return _default
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def reset_default_breaker() -> None:
|
|
115
|
+
global _default
|
|
116
|
+
_default = None
|
booking_agent/config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""booking-agent configuration (USP env subset).
|
|
2
|
+
|
|
3
|
+
Reads settings (process environment, then the shipped defaults) and exposes them
|
|
4
|
+
as a small Config object.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from . import settings
|
|
12
|
+
from .platform_profile import platform_profile_url
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Config:
|
|
17
|
+
"""Resolved USP configuration."""
|
|
18
|
+
|
|
19
|
+
usp_agent_profile_url: str = ""
|
|
20
|
+
usp_platform_name: Optional[str] = None
|
|
21
|
+
usp_platform_contact: Optional[str] = None
|
|
22
|
+
usp_registry_url: Optional[str] = None
|
|
23
|
+
# Enforcement posture for the USP Section 10.1.6 privileged-operation floor:
|
|
24
|
+
# required | preferred. None means "use the default".
|
|
25
|
+
usp_auth_scoped_credential: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
google_calendar_client_id: str = ""
|
|
28
|
+
google_calendar_client_secret: str = ""
|
|
29
|
+
google_calendar_redirect_port: int = 8098
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _optional_strip(value: Optional[str]) -> Optional[str]:
|
|
33
|
+
if value is None:
|
|
34
|
+
return None
|
|
35
|
+
stripped = value.strip()
|
|
36
|
+
return stripped or None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config() -> Config:
|
|
40
|
+
"""Build a Config from the process environment and the shipped defaults."""
|
|
41
|
+
get = settings.get
|
|
42
|
+
return Config(
|
|
43
|
+
usp_agent_profile_url=platform_profile_url(),
|
|
44
|
+
usp_platform_name=get("USP_PLATFORM_NAME"),
|
|
45
|
+
usp_platform_contact=get("USP_PLATFORM_CONTACT"),
|
|
46
|
+
usp_registry_url=get("USP_REGISTRY_URL"),
|
|
47
|
+
usp_auth_scoped_credential=_optional_strip(get("BOOKING_AGENT_USP_AUTH_SCOPED_CREDENTIAL")),
|
|
48
|
+
google_calendar_client_id=get("GOOGLE_CALENDAR_CLIENT_ID") or "",
|
|
49
|
+
google_calendar_client_secret=get("GOOGLE_CALENDAR_CLIENT_SECRET") or "",
|
|
50
|
+
google_calendar_redirect_port=settings.get_int("GOOGLE_CALENDAR_REDIRECT_PORT"),
|
|
51
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Shipped defaults for booking-agent. Do not edit this copy.
|
|
2
|
+
# Override a value in the process environment or ~/.booking-agent/.env.
|
|
3
|
+
# `booking-agent config show` prints each effective value and its source.
|
|
4
|
+
# A `# NAME=` line has no default.
|
|
5
|
+
|
|
6
|
+
# --- Platform identity ---------------------------------------------------------
|
|
7
|
+
# Fetched by businesses from the USP-Agent and UCP-Agent headers.
|
|
8
|
+
USP_AGENT_PROFILE_URL=https://profile.booking-agent.dev/platform-profile.json
|
|
9
|
+
USP_REGISTRY_URL=https://www.wixapis.com/_api/usp-registry/registry/search_services
|
|
10
|
+
USP_PLATFORM_NAME=USP Agent
|
|
11
|
+
USP_PLATFORM_CONTACT=support@booking-agent.dev
|
|
12
|
+
|
|
13
|
+
# --- Install -------------------------------------------------------------------
|
|
14
|
+
# Sessions, tokens, and traces. Holds the user .env, so override it only in the
|
|
15
|
+
# process environment.
|
|
16
|
+
BOOKING_AGENT_SESSION_DIR=~/.booking-agent
|
|
17
|
+
BOOKING_AGENT_MAX_SESSIONS=30
|
|
18
|
+
LINK_CLI_BIN=link-cli
|
|
19
|
+
|
|
20
|
+
# --- Authorization -------------------------------------------------------------
|
|
21
|
+
# Booking-scoped credential floor: required or preferred.
|
|
22
|
+
BOOKING_AGENT_USP_AUTH_SCOPED_CREDENTIAL=required
|
|
23
|
+
|
|
24
|
+
# --- HTTP (seconds unless named otherwise) -------------------------------------
|
|
25
|
+
BOOKING_AGENT_HTTP_TIMEOUT=15
|
|
26
|
+
BOOKING_AGENT_HTTP_CONNECT_TIMEOUT=5
|
|
27
|
+
BOOKING_AGENT_HTTP_MAX_RETRIES=4
|
|
28
|
+
BOOKING_AGENT_HTTP_BACKOFF_BASE=1.0
|
|
29
|
+
BOOKING_AGENT_HTTP_BACKOFF_MAX=300.0
|
|
30
|
+
BOOKING_AGENT_CIRCUIT_FAILURE_THRESHOLD=5
|
|
31
|
+
BOOKING_AGENT_CIRCUIT_COOLDOWN_SECONDS=30
|
|
32
|
+
BOOKING_AGENT_CIRCUIT_DISABLED=0
|
|
33
|
+
# Minimum gap between requests to one business host.
|
|
34
|
+
BOOKING_AGENT_MERCHANT_REQUEST_DELAY_MS=200
|
|
35
|
+
BOOKING_AGENT_MERCHANT_HOST_CONCURRENCY=3
|
|
36
|
+
BOOKING_AGENT_DISCOVERY_CONCURRENCY=8
|
|
37
|
+
BOOKING_AGENT_PROFILE_CACHE_TTL_SECONDS=900
|
|
38
|
+
# 1 writes a redacted request log to <session dir>/http_trace.log.
|
|
39
|
+
BOOKING_AGENT_HTTP_TRACE=0
|
|
40
|
+
|
|
41
|
+
# --- Address -------------------------------------------------------------------
|
|
42
|
+
BOOKING_AGENT_GEOCODER_URL=https://nominatim.openstreetmap.org/search
|
|
43
|
+
|
|
44
|
+
# --- Google Calendar -----------------------------------------------------------
|
|
45
|
+
# The operator's OAuth app, needed only for a local calendar sign-in.
|
|
46
|
+
# GOOGLE_CALENDAR_CLIENT_ID=
|
|
47
|
+
# GOOGLE_CALENDAR_CLIENT_SECRET=
|
|
48
|
+
GOOGLE_CALENDAR_REDIRECT_PORT=8098
|
|
49
|
+
# A folder under the session dir, or an absolute path.
|
|
50
|
+
GOOGLE_CALENDAR_TOKEN_CACHE_DIR=.google-calendar-tokens
|