spooling 0.2.9__tar.gz → 0.2.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spooling-0.2.9/spooling.egg-info → spooling-0.2.10}/PKG-INFO +1 -1
- {spooling-0.2.9 → spooling-0.2.10}/pyproject.toml +1 -1
- {spooling-0.2.9 → spooling-0.2.10}/spooling/cloud.py +167 -24
- {spooling-0.2.9 → spooling-0.2.10/spooling.egg-info}/PKG-INFO +1 -1
- {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/SOURCES.txt +1 -0
- spooling-0.2.10/tests/test_cloud_errors.py +219 -0
- {spooling-0.2.9 → spooling-0.2.10}/LICENSE +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/README.md +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/setup.cfg +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/__init__.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/agent.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/classifiers.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/cli.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/config.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/base.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/types.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/db.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/embeddings.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/evals.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/experiments.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/ingest.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/mcp_server.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/optimize.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/parser.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/pricing.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/base.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/claude_code.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/codex.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/github.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/redact.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/remote_otel.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/schema.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/sdk.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/search.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/server.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/sessions.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/stats.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/tracing.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/tunnel.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling/watcher.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/tests/test_claude_code_provider.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/tests/test_session_ids.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/tests/test_storage_optimizations.py +0 -0
- {spooling-0.2.9 → spooling-0.2.10}/tests/test_tunnel_auth.py +0 -0
|
@@ -4,13 +4,17 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import ssl
|
|
7
9
|
import time
|
|
8
10
|
from datetime import datetime, timedelta, timezone
|
|
9
11
|
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
10
13
|
|
|
11
14
|
import click
|
|
12
15
|
import httpx
|
|
13
16
|
from rich.console import Console
|
|
17
|
+
from rich.markup import escape
|
|
14
18
|
|
|
15
19
|
from spooling.db import get_connection
|
|
16
20
|
from spooling.redact import redact_messages, redact_traces
|
|
@@ -70,6 +74,143 @@ def _api_base() -> str:
|
|
|
70
74
|
return cfg.get("api_url") or os.environ.get("SPOOLING_CLOUD_URL") or DEFAULT_API
|
|
71
75
|
|
|
72
76
|
|
|
77
|
+
# --- Friendly network errors -------------------------------------------------
|
|
78
|
+
#
|
|
79
|
+
# Raw exceptions like "[Errno 8] nodename nor servname provided, or not known"
|
|
80
|
+
# tell a user nothing. Everything that talks to the cloud funnels its errors
|
|
81
|
+
# through _explain_error / _explain_http so they get a plain-English cause and
|
|
82
|
+
# the next step.
|
|
83
|
+
|
|
84
|
+
_SELF_HOST_HINT = (
|
|
85
|
+
"Using a self-hosted or staging server? Point the CLI at it with\n"
|
|
86
|
+
" spooling cloud login --key sk_... --api-url https://your-host\n"
|
|
87
|
+
" or set SPOOLING_CLOUD_URL."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _host_port(base: str) -> tuple[str, int]:
|
|
92
|
+
try:
|
|
93
|
+
u = urlparse(base)
|
|
94
|
+
host = u.hostname or base
|
|
95
|
+
port = u.port or (80 if u.scheme == "http" else 443)
|
|
96
|
+
except ValueError: # e.g. port out of range in a hand-typed URL
|
|
97
|
+
return base, 443
|
|
98
|
+
return host, port
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _exc_chain(e: BaseException):
|
|
102
|
+
seen = set()
|
|
103
|
+
while e is not None and id(e) not in seen:
|
|
104
|
+
seen.add(id(e))
|
|
105
|
+
yield e
|
|
106
|
+
e = e.__cause__ or e.__context__
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_DNS_MARKERS = (
|
|
110
|
+
"nodename nor servname", # macOS
|
|
111
|
+
"name or service not known", # Linux (glibc)
|
|
112
|
+
"temporary failure in name resolution",
|
|
113
|
+
"no address associated with hostname",
|
|
114
|
+
"getaddrinfo failed", # Windows
|
|
115
|
+
"no such host",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_dns_error(e: BaseException) -> bool:
|
|
120
|
+
for x in _exc_chain(e):
|
|
121
|
+
if isinstance(x, socket.gaierror):
|
|
122
|
+
return True
|
|
123
|
+
if any(m in str(x).lower() for m in _DNS_MARKERS):
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _explain_error(e: BaseException, base: str) -> str:
|
|
129
|
+
"""Return a plain-English explanation (rich markup) for a failed request.
|
|
130
|
+
|
|
131
|
+
Never raises: it runs inside error handlers, where a second exception would
|
|
132
|
+
hide the original problem.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
return _explain_error_impl(e, base)
|
|
136
|
+
except Exception: # noqa: BLE001
|
|
137
|
+
return escape(str(e) or type(e).__name__)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _explain_error_impl(e: BaseException, base: str) -> str:
|
|
141
|
+
host, port = _host_port(base)
|
|
142
|
+
chain = list(_exc_chain(e))
|
|
143
|
+
if _is_dns_error(e):
|
|
144
|
+
return (
|
|
145
|
+
f"Can't reach Spooling Cloud at {escape(base)}\n"
|
|
146
|
+
f" The hostname '{escape(host)}' doesn't resolve (DNS lookup failed).\n"
|
|
147
|
+
f" Either you're offline, or that server doesn't exist (yet).\n"
|
|
148
|
+
f" - Offline or on a VPN? Check your connection and try again.\n"
|
|
149
|
+
f" - {_SELF_HOST_HINT}"
|
|
150
|
+
)
|
|
151
|
+
if any(isinstance(x, ConnectionRefusedError) for x in chain):
|
|
152
|
+
return (
|
|
153
|
+
f"Connection refused by {escape(host)}:{port}\n"
|
|
154
|
+
f" The host is reachable but nothing is listening. Is the sync server running?\n"
|
|
155
|
+
f" - {_SELF_HOST_HINT}"
|
|
156
|
+
)
|
|
157
|
+
if isinstance(e, httpx.TimeoutException) or any(isinstance(x, (TimeoutError, socket.timeout)) for x in chain):
|
|
158
|
+
return (
|
|
159
|
+
f"Timed out talking to {escape(base)}\n"
|
|
160
|
+
f" The server may be starting up or overloaded. Try again in a moment."
|
|
161
|
+
)
|
|
162
|
+
if any(isinstance(x, ssl.SSLError) for x in chain):
|
|
163
|
+
return (
|
|
164
|
+
f"TLS/SSL error connecting to {escape(host)}\n"
|
|
165
|
+
f" {escape(str(e))}\n"
|
|
166
|
+
f" Check the server's certificate, and that the URL scheme (http/https) is right."
|
|
167
|
+
)
|
|
168
|
+
if isinstance(e, httpx.ConnectError):
|
|
169
|
+
return f"Can't connect to {escape(base)}: {escape(str(e) or type(e).__name__)}"
|
|
170
|
+
return escape(str(e) or type(e).__name__)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _explain_http(e: "httpx.HTTPStatusError") -> str:
|
|
174
|
+
code = e.response.status_code
|
|
175
|
+
body = escape(e.response.text[:200])
|
|
176
|
+
if code == 401:
|
|
177
|
+
return (
|
|
178
|
+
f"HTTP 401: the server rejected your API key.\n"
|
|
179
|
+
f" Create a new key in the admin app (Settings > API keys), then run\n"
|
|
180
|
+
f" spooling cloud login --key sk_...\n"
|
|
181
|
+
f" {body}"
|
|
182
|
+
)
|
|
183
|
+
if code == 403:
|
|
184
|
+
return f"HTTP 403: this API key isn't allowed to do that (missing scope or no workspace).\n {body}"
|
|
185
|
+
if code == 429:
|
|
186
|
+
retry = e.response.headers.get("retry-after")
|
|
187
|
+
return f"HTTP 429: rate limited{f', retry in {retry}s' if retry else ''}. Slow down or try again shortly."
|
|
188
|
+
if code >= 500:
|
|
189
|
+
return f"HTTP {code}: the server hit an error. This is not a problem with your data; try again shortly.\n {body}"
|
|
190
|
+
return f"HTTP {code}: {body}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fail(message: str) -> "None":
|
|
194
|
+
"""Print an error and exit non-zero so scripts and cron can detect it."""
|
|
195
|
+
console.print(f"[red]{message}[/red]")
|
|
196
|
+
raise click.exceptions.Exit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _preflight(base: str) -> None:
|
|
200
|
+
"""Fail fast, before any sync/redaction work, if the server can't be resolved.
|
|
201
|
+
|
|
202
|
+
Only checks DNS (no request is sent, so it works with any server version).
|
|
203
|
+
Connection-level problems are still reported by the request itself.
|
|
204
|
+
"""
|
|
205
|
+
host, port = _host_port(base)
|
|
206
|
+
try:
|
|
207
|
+
socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
208
|
+
except OSError as e:
|
|
209
|
+
console.print(f"[red]{_explain_error(e, base)}[/red]")
|
|
210
|
+
console.print("[dim]Nothing was sent. Your local sessions are untouched.[/dim]")
|
|
211
|
+
raise click.exceptions.Exit(1)
|
|
212
|
+
|
|
213
|
+
|
|
73
214
|
@click.group()
|
|
74
215
|
def cloud():
|
|
75
216
|
"""Spooling Cloud: push local sessions to api.spooling.ai."""
|
|
@@ -93,8 +234,10 @@ def cloud_login(key: str, api_url: str | None):
|
|
|
93
234
|
stats = r.json()
|
|
94
235
|
console.print(f"[green]Logged in to {base}[/green]")
|
|
95
236
|
console.print(f" sessions in cloud: [bold]{stats.get('sessions', 0)}[/bold]")
|
|
237
|
+
except httpx.HTTPStatusError as e:
|
|
238
|
+
console.print(f"[red]Saved key, but the server rejected the check:\n {_explain_http(e)}[/red]")
|
|
96
239
|
except Exception as e:
|
|
97
|
-
console.print(f"[red]Saved key, but
|
|
240
|
+
console.print(f"[red]Saved key, but couldn't verify it.\n {_explain_error(e, base)}[/red]")
|
|
98
241
|
|
|
99
242
|
|
|
100
243
|
@cloud.command("status")
|
|
@@ -114,8 +257,10 @@ def cloud_status():
|
|
|
114
257
|
console.print(f" messages: [bold]{s.get('messages', 0)}[/bold]")
|
|
115
258
|
console.print(f" providers: [bold]{s.get('providers', 0)}[/bold]")
|
|
116
259
|
console.print(f" cost: [bold]${s.get('cost', 0):.2f}[/bold]")
|
|
260
|
+
except httpx.HTTPStatusError as e:
|
|
261
|
+
_fail(f"{_explain_http(e)}")
|
|
117
262
|
except Exception as e:
|
|
118
|
-
|
|
263
|
+
_fail(f"{_explain_error(e, base)}")
|
|
119
264
|
|
|
120
265
|
|
|
121
266
|
@cloud.command("logout")
|
|
@@ -395,14 +540,9 @@ def _push_trace_batches(
|
|
|
395
540
|
else:
|
|
396
541
|
log(f" pushed {acc} trace(s), {spans} span(s)")
|
|
397
542
|
except httpx.HTTPStatusError as e:
|
|
398
|
-
return (
|
|
399
|
-
accepted,
|
|
400
|
-
rejected,
|
|
401
|
-
spans_total,
|
|
402
|
-
f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
|
403
|
-
)
|
|
543
|
+
return accepted, rejected, spans_total, _explain_http(e)
|
|
404
544
|
except Exception as e:
|
|
405
|
-
return accepted, rejected, spans_total,
|
|
545
|
+
return accepted, rejected, spans_total, _explain_error(e, base)
|
|
406
546
|
return accepted, rejected, spans_total, None
|
|
407
547
|
|
|
408
548
|
|
|
@@ -448,9 +588,9 @@ def _push_batches(
|
|
|
448
588
|
else:
|
|
449
589
|
log(f" pushed {accepted} sessions")
|
|
450
590
|
except httpx.HTTPStatusError as e:
|
|
451
|
-
return total, rejected,
|
|
591
|
+
return total, rejected, _explain_http(e)
|
|
452
592
|
except Exception as e:
|
|
453
|
-
return total, rejected,
|
|
593
|
+
return total, rejected, _explain_error(e, base)
|
|
454
594
|
return total, rejected, None
|
|
455
595
|
|
|
456
596
|
|
|
@@ -559,6 +699,8 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
559
699
|
return
|
|
560
700
|
headers = _auth_headers()
|
|
561
701
|
base = _api_base()
|
|
702
|
+
if not dry_run:
|
|
703
|
+
_preflight(base) # fail fast, before syncing and redacting, if the server can't be reached
|
|
562
704
|
if do_sync:
|
|
563
705
|
_sync_local()
|
|
564
706
|
sessions = _collect_sessions(
|
|
@@ -607,8 +749,7 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
607
749
|
return
|
|
608
750
|
total, rejected, err = _push_batches(sessions, batch, base, headers, console.print, copy=copy)
|
|
609
751
|
if err:
|
|
610
|
-
|
|
611
|
-
return
|
|
752
|
+
_fail(err)
|
|
612
753
|
if rejected:
|
|
613
754
|
console.print(
|
|
614
755
|
f"[green]Done.[/green] {total} sessions synced to {base} "
|
|
@@ -649,8 +790,7 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
649
790
|
traces, trace_batch_size, base, headers, console.print,
|
|
650
791
|
)
|
|
651
792
|
if t_err:
|
|
652
|
-
|
|
653
|
-
return
|
|
793
|
+
_fail(f"Sessions were pushed, but the trace/span upload failed:\n {t_err}")
|
|
654
794
|
msg = f"[green]Done.[/green] {t_acc} trace(s) and {t_spans} span(s) synced"
|
|
655
795
|
if t_rej:
|
|
656
796
|
msg += f" ([yellow]{t_rej} trace(s) rejected[/yellow])"
|
|
@@ -715,7 +855,7 @@ def cloud_watch(interval: int, limit: int, batch: int, lookback: int, no_redact:
|
|
|
715
855
|
console.print(f" [dim]Redacted {redaction_count} secret(s) before push[/dim]")
|
|
716
856
|
total, rejected, err = _push_batches(sessions, batch, base, headers, console.print)
|
|
717
857
|
if err:
|
|
718
|
-
console.print(f"[red]{err}[/red] (will retry next cycle)")
|
|
858
|
+
console.print(f"[red]{err.splitlines()[0]}[/red] (will retry next cycle)")
|
|
719
859
|
else:
|
|
720
860
|
# Advance watermark to the cycle start; the lookback window catches
|
|
721
861
|
# sessions whose started_at slid backwards or whose messages were
|
|
@@ -790,6 +930,7 @@ def cloud_delete(project: str | None, cwd_substr: str | None, session_id: str |
|
|
|
790
930
|
|
|
791
931
|
headers = _auth_headers()
|
|
792
932
|
base = _api_base()
|
|
933
|
+
_preflight(base)
|
|
793
934
|
params = {"dry_run": "true" if dry_run else "false"}
|
|
794
935
|
if project:
|
|
795
936
|
params["project"] = project
|
|
@@ -810,10 +951,10 @@ def cloud_delete(project: str | None, cwd_substr: str | None, session_id: str |
|
|
|
810
951
|
r.raise_for_status()
|
|
811
952
|
data = r.json()
|
|
812
953
|
except httpx.HTTPStatusError as e:
|
|
813
|
-
console.print(f"[red]
|
|
954
|
+
console.print(f"[red]{_explain_http(e)}[/red]")
|
|
814
955
|
return
|
|
815
956
|
except Exception as e:
|
|
816
|
-
console.print(f"[red]{e}[/red]")
|
|
957
|
+
console.print(f"[red]{_explain_error(e, base)}[/red]")
|
|
817
958
|
return
|
|
818
959
|
|
|
819
960
|
matched = data.get("matched", 0)
|
|
@@ -928,9 +1069,9 @@ def _pull_sessions(
|
|
|
928
1069
|
r = client.get(f"{base}/v1/sessions", headers=headers, params=params)
|
|
929
1070
|
r.raise_for_status()
|
|
930
1071
|
except httpx.HTTPStatusError as e:
|
|
931
|
-
return 0, 0,
|
|
1072
|
+
return 0, 0, _explain_http(e)
|
|
932
1073
|
except Exception as e:
|
|
933
|
-
return 0, 0,
|
|
1074
|
+
return 0, 0, _explain_error(e, base)
|
|
934
1075
|
|
|
935
1076
|
data = r.json()
|
|
936
1077
|
cloud_sessions = data.get("sessions", [])
|
|
@@ -979,6 +1120,7 @@ def pull(limit: int, batch: int, dry_run: bool):
|
|
|
979
1120
|
"""
|
|
980
1121
|
headers = _auth_headers()
|
|
981
1122
|
base = _api_base()
|
|
1123
|
+
_preflight(base)
|
|
982
1124
|
|
|
983
1125
|
cfg = _load_config()
|
|
984
1126
|
last = cfg.get("last_pull_at")
|
|
@@ -996,10 +1138,10 @@ def pull(limit: int, batch: int, dry_run: bool):
|
|
|
996
1138
|
r = httpx.get(f"{base}/v1/sessions", headers=headers, params=params, timeout=30)
|
|
997
1139
|
r.raise_for_status()
|
|
998
1140
|
except httpx.HTTPStatusError as e:
|
|
999
|
-
console.print(f"[red]
|
|
1141
|
+
console.print(f"[red]{_explain_http(e)}[/red]")
|
|
1000
1142
|
return
|
|
1001
1143
|
except Exception as e:
|
|
1002
|
-
console.print(f"[red]{e}[/red]")
|
|
1144
|
+
console.print(f"[red]{_explain_error(e, base)}[/red]")
|
|
1003
1145
|
return
|
|
1004
1146
|
|
|
1005
1147
|
data = r.json()
|
|
@@ -1070,6 +1212,7 @@ def sync(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
1070
1212
|
"""
|
|
1071
1213
|
headers = _auth_headers()
|
|
1072
1214
|
base = _api_base()
|
|
1215
|
+
_preflight(base)
|
|
1073
1216
|
|
|
1074
1217
|
# Phase 1: Push local → cloud
|
|
1075
1218
|
console.print("[bold]Phase 1: Push local → cloud[/bold]")
|
|
@@ -1115,10 +1258,10 @@ def sync(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
1115
1258
|
r = httpx.get(f"{base}/v1/sessions", headers=headers, params=params, timeout=30)
|
|
1116
1259
|
r.raise_for_status()
|
|
1117
1260
|
except httpx.HTTPStatusError as e:
|
|
1118
|
-
console.print(f" [red]
|
|
1261
|
+
console.print(f" [red]{_explain_http(e)}[/red]")
|
|
1119
1262
|
return
|
|
1120
1263
|
except Exception as e:
|
|
1121
|
-
console.print(f" [red]{e}[/red]")
|
|
1264
|
+
console.print(f" [red]{_explain_error(e, base)}[/red]")
|
|
1122
1265
|
return
|
|
1123
1266
|
|
|
1124
1267
|
data = r.json()
|
|
@@ -64,6 +64,7 @@ spooling/providers/opencode.py
|
|
|
64
64
|
spooling/providers/session_file.py
|
|
65
65
|
spooling/providers/windsurf.py
|
|
66
66
|
tests/test_claude_code_provider.py
|
|
67
|
+
tests/test_cloud_errors.py
|
|
67
68
|
tests/test_mcp_auth_middleware.py
|
|
68
69
|
tests/test_session_ids.py
|
|
69
70
|
tests/test_storage_optimizations.py
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Tests for friendly cloud errors: DNS / refused / TLS / HTTP status handling,
|
|
2
|
+
fail-fast preflight, and non-zero exit codes for `spooling push`."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
import ssl
|
|
7
|
+
import threading
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
from click.testing import CliRunner
|
|
13
|
+
|
|
14
|
+
from spooling import cloud
|
|
15
|
+
from spooling.cli import cli
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- helpers -----------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
21
|
+
status = 200
|
|
22
|
+
body = {"sessions": 3, "messages": 9, "providers": 1, "cost": 1.5,
|
|
23
|
+
"accepted": 1, "rejected": 0}
|
|
24
|
+
seen: list[str] = []
|
|
25
|
+
|
|
26
|
+
def log_message(self, *a): # silence
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def _go(self):
|
|
30
|
+
type(self).seen.append(f"{self.command} {self.path}")
|
|
31
|
+
payload = json.dumps(type(self).body if type(self).status == 200 else {"detail": "nope"}).encode()
|
|
32
|
+
self.send_response(type(self).status)
|
|
33
|
+
if type(self).status == 429:
|
|
34
|
+
self.send_header("Retry-After", "42")
|
|
35
|
+
self.send_header("Content-Type", "application/json")
|
|
36
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
37
|
+
self.end_headers()
|
|
38
|
+
self.wfile.write(payload)
|
|
39
|
+
|
|
40
|
+
do_GET = do_POST = do_DELETE = _go
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def server():
|
|
45
|
+
"""Run a throwaway HTTP server; set .status to choose the response code."""
|
|
46
|
+
handler = type("H", (_Handler,), {"status": 200, "seen": []})
|
|
47
|
+
srv = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
48
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
49
|
+
t.start()
|
|
50
|
+
srv.handler = handler
|
|
51
|
+
srv.url = f"http://127.0.0.1:{srv.server_address[1]}"
|
|
52
|
+
yield srv
|
|
53
|
+
srv.shutdown()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def logged_in(tmp_path, monkeypatch):
|
|
58
|
+
"""Isolated config so tests never touch the real ~/.config/spooling."""
|
|
59
|
+
cfg = tmp_path / "cloud.json"
|
|
60
|
+
monkeypatch.setattr(cloud, "CONFIG_PATH", cfg)
|
|
61
|
+
monkeypatch.delenv("SPOOLING_CLOUD_URL", raising=False)
|
|
62
|
+
monkeypatch.delenv("SPOOLING_CLOUD_API_KEY", raising=False)
|
|
63
|
+
|
|
64
|
+
def point_at(url):
|
|
65
|
+
cfg.write_text(json.dumps({"api_key": "sk_test", "api_url": url}))
|
|
66
|
+
|
|
67
|
+
point_at(cloud.DEFAULT_API)
|
|
68
|
+
return point_at
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def free_port() -> int:
|
|
72
|
+
s = socket.socket()
|
|
73
|
+
s.bind(("127.0.0.1", 0))
|
|
74
|
+
port = s.getsockname()[1]
|
|
75
|
+
s.close()
|
|
76
|
+
return port
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run(*args):
|
|
80
|
+
return CliRunner().invoke(cli, list(args))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def one_session():
|
|
84
|
+
return [{"id": "s1", "provider_id": "claude-code", "project": "p", "title": "t",
|
|
85
|
+
"messages": [], "message_count": 0}]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- DNS ---------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _no_dns(monkeypatch):
|
|
91
|
+
def boom(*a, **k):
|
|
92
|
+
raise socket.gaierror(8, "nodename nor servname provided, or not known")
|
|
93
|
+
monkeypatch.setattr(socket, "getaddrinfo", boom)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_push_fails_fast_on_dns_before_any_work(logged_in, monkeypatch):
|
|
97
|
+
_no_dns(monkeypatch)
|
|
98
|
+
monkeypatch.setattr(cloud, "_sync_local", lambda *a, **k: pytest.fail("synced before preflight"))
|
|
99
|
+
monkeypatch.setattr(cloud, "_collect_sessions", lambda **k: pytest.fail("collected before preflight"))
|
|
100
|
+
|
|
101
|
+
r = run("push", "--with-spans")
|
|
102
|
+
|
|
103
|
+
assert r.exit_code == 1
|
|
104
|
+
assert "Can't reach Spooling Cloud at https://api.spooling.ai" in r.output
|
|
105
|
+
assert "doesn't resolve" in r.output and "api.spooling.ai" in r.output
|
|
106
|
+
assert "--api-url" in r.output and "SPOOLING_CLOUD_URL" in r.output
|
|
107
|
+
assert "Nothing was sent" in r.output
|
|
108
|
+
assert "Errno" not in r.output # the raw error is gone
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_push_dry_run_needs_no_network(logged_in, monkeypatch):
|
|
112
|
+
_no_dns(monkeypatch)
|
|
113
|
+
monkeypatch.setattr(cloud, "_collect_sessions", lambda **k: one_session())
|
|
114
|
+
r = run("push", "--dry-run", "--no-sync")
|
|
115
|
+
assert r.exit_code == 0
|
|
116
|
+
assert "would land in" in r.output
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("cmd", [["cloud", "pull"], ["cloud", "sync"], ["cloud", "delete", "--all", "--yes"]])
|
|
120
|
+
def test_other_network_commands_preflight_too(logged_in, monkeypatch, cmd):
|
|
121
|
+
_no_dns(monkeypatch)
|
|
122
|
+
r = run(*cmd)
|
|
123
|
+
assert r.exit_code == 1 and "doesn't resolve" in r.output
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.parametrize("msg", [
|
|
127
|
+
"[Errno 8] nodename nor servname provided, or not known", # macOS
|
|
128
|
+
"[Errno -2] Name or service not known", # Linux
|
|
129
|
+
"[Errno -3] Temporary failure in name resolution",
|
|
130
|
+
"[Errno 11001] getaddrinfo failed", # Windows
|
|
131
|
+
])
|
|
132
|
+
def test_dns_detected_from_message_even_when_wrapped(msg):
|
|
133
|
+
inner = OSError(msg)
|
|
134
|
+
wrapped = httpx.ConnectError("boom")
|
|
135
|
+
wrapped.__cause__ = inner
|
|
136
|
+
assert cloud._is_dns_error(wrapped)
|
|
137
|
+
assert "doesn't resolve" in cloud._explain_error(wrapped, "https://x.example")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- connection-level errors -------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def test_connection_refused_is_explained(logged_in):
|
|
143
|
+
logged_in(f"http://127.0.0.1:{free_port()}")
|
|
144
|
+
r = run("cloud", "status")
|
|
145
|
+
assert r.exit_code == 1
|
|
146
|
+
assert "Connection refused" in r.output and "nothing is listening" in r.output
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_timeout_and_tls_messages():
|
|
150
|
+
t = cloud._explain_error(httpx.ReadTimeout("slow"), "https://x.example")
|
|
151
|
+
assert "Timed out" in t
|
|
152
|
+
inner = ssl.SSLError("CERTIFICATE_VERIFY_FAILED")
|
|
153
|
+
e = httpx.ConnectError("tls")
|
|
154
|
+
e.__cause__ = inner
|
|
155
|
+
assert "TLS/SSL error" in cloud._explain_error(e, "https://x.example")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_explainer_never_raises_on_garbage():
|
|
159
|
+
assert cloud._explain_error(ValueError("x"), "http://host:99999999") # bad port
|
|
160
|
+
assert cloud._explain_error(RuntimeError(""), "") # empty everything
|
|
161
|
+
assert cloud._explain_error(Exception("[red]markup[/red]"), "https://x") # rich markup in message
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --- HTTP status codes -------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
@pytest.mark.parametrize("status,needle", [
|
|
167
|
+
(401, "rejected your API key"),
|
|
168
|
+
(403, "isn't allowed"),
|
|
169
|
+
(429, "rate limited, retry in 42s"),
|
|
170
|
+
(500, "not a problem with your data"),
|
|
171
|
+
])
|
|
172
|
+
def test_http_errors_are_explained_and_exit_nonzero(logged_in, server, status, needle):
|
|
173
|
+
server.handler.status = status
|
|
174
|
+
logged_in(server.url)
|
|
175
|
+
r = run("cloud", "status")
|
|
176
|
+
assert r.exit_code == 1
|
|
177
|
+
assert needle in r.output
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_status_success(logged_in, server):
|
|
181
|
+
logged_in(server.url)
|
|
182
|
+
r = run("cloud", "status")
|
|
183
|
+
assert r.exit_code == 0
|
|
184
|
+
assert "sessions: 3" in r.output
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- push exit codes ---------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def test_push_success_exits_zero(logged_in, server, monkeypatch):
|
|
190
|
+
logged_in(server.url)
|
|
191
|
+
monkeypatch.setattr(cloud, "_collect_sessions", lambda **k: one_session())
|
|
192
|
+
r = run("push", "--no-sync")
|
|
193
|
+
assert r.exit_code == 0 and "Done." in r.output
|
|
194
|
+
assert "POST /v1/sessions/batch" in server.handler.seen
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.parametrize("status", [401, 500])
|
|
198
|
+
def test_push_server_error_exits_nonzero(logged_in, server, monkeypatch, status):
|
|
199
|
+
server.handler.status = status
|
|
200
|
+
logged_in(server.url)
|
|
201
|
+
monkeypatch.setattr(cloud, "_collect_sessions", lambda **k: one_session())
|
|
202
|
+
r = run("push", "--no-sync")
|
|
203
|
+
assert r.exit_code == 1
|
|
204
|
+
assert "Done." not in r.output
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_push_connection_refused_exits_nonzero(logged_in, monkeypatch):
|
|
208
|
+
logged_in(f"http://127.0.0.1:{free_port()}")
|
|
209
|
+
monkeypatch.setattr(cloud, "_collect_sessions", lambda **k: one_session())
|
|
210
|
+
r = run("push", "--no-sync")
|
|
211
|
+
assert r.exit_code == 1 and "Connection refused" in r.output
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_login_check_failure_still_saves_key_and_explains(tmp_path, monkeypatch):
|
|
215
|
+
monkeypatch.setattr(cloud, "CONFIG_PATH", tmp_path / "cloud.json")
|
|
216
|
+
port = free_port()
|
|
217
|
+
r = run("cloud", "login", "--key", "sk_new", "--api-url", f"http://127.0.0.1:{port}")
|
|
218
|
+
assert "couldn't verify it" in r.output and "Connection refused" in r.output
|
|
219
|
+
assert json.loads((tmp_path / "cloud.json").read_text())["api_key"] == "sk_new"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|