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.
Files changed (73) hide show
  1. {spooling-0.2.9/spooling.egg-info → spooling-0.2.10}/PKG-INFO +1 -1
  2. {spooling-0.2.9 → spooling-0.2.10}/pyproject.toml +1 -1
  3. {spooling-0.2.9 → spooling-0.2.10}/spooling/cloud.py +167 -24
  4. {spooling-0.2.9 → spooling-0.2.10/spooling.egg-info}/PKG-INFO +1 -1
  5. {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/SOURCES.txt +1 -0
  6. spooling-0.2.10/tests/test_cloud_errors.py +219 -0
  7. {spooling-0.2.9 → spooling-0.2.10}/LICENSE +0 -0
  8. {spooling-0.2.9 → spooling-0.2.10}/README.md +0 -0
  9. {spooling-0.2.9 → spooling-0.2.10}/setup.cfg +0 -0
  10. {spooling-0.2.9 → spooling-0.2.10}/spooling/__init__.py +0 -0
  11. {spooling-0.2.9 → spooling-0.2.10}/spooling/agent.py +0 -0
  12. {spooling-0.2.9 → spooling-0.2.10}/spooling/classifiers.py +0 -0
  13. {spooling-0.2.9 → spooling-0.2.10}/spooling/cli.py +0 -0
  14. {spooling-0.2.9 → spooling-0.2.10}/spooling/config.py +0 -0
  15. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/__init__.py +0 -0
  16. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/base.py +0 -0
  17. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/bigquery.py +0 -0
  18. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/clickhouse.py +0 -0
  19. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/factory.py +0 -0
  20. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/mongodb.py +0 -0
  21. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/mysql.py +0 -0
  22. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/postgresql.py +0 -0
  23. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/rest/__init__.py +0 -0
  24. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/rest/base.py +0 -0
  25. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/rest/shopify_connectors.py +0 -0
  26. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/snowflake.py +0 -0
  27. {spooling-0.2.9 → spooling-0.2.10}/spooling/connectors/types.py +0 -0
  28. {spooling-0.2.9 → spooling-0.2.10}/spooling/db.py +0 -0
  29. {spooling-0.2.9 → spooling-0.2.10}/spooling/embeddings.py +0 -0
  30. {spooling-0.2.9 → spooling-0.2.10}/spooling/evals.py +0 -0
  31. {spooling-0.2.9 → spooling-0.2.10}/spooling/experiments.py +0 -0
  32. {spooling-0.2.9 → spooling-0.2.10}/spooling/ingest.py +0 -0
  33. {spooling-0.2.9 → spooling-0.2.10}/spooling/mcp_server.py +0 -0
  34. {spooling-0.2.9 → spooling-0.2.10}/spooling/optimize.py +0 -0
  35. {spooling-0.2.9 → spooling-0.2.10}/spooling/parser.py +0 -0
  36. {spooling-0.2.9 → spooling-0.2.10}/spooling/pricing.py +0 -0
  37. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/__init__.py +0 -0
  38. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/antigravity.py +0 -0
  39. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/base.py +0 -0
  40. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/claude_code.py +0 -0
  41. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/codex.py +0 -0
  42. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/copilot.py +0 -0
  43. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/cortex_code.py +0 -0
  44. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/cursor.py +0 -0
  45. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/gemini.py +0 -0
  46. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/github.py +0 -0
  47. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/gitlab.py +0 -0
  48. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/kiro.py +0 -0
  49. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/opencode.py +0 -0
  50. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/session_file.py +0 -0
  51. {spooling-0.2.9 → spooling-0.2.10}/spooling/providers/windsurf.py +0 -0
  52. {spooling-0.2.9 → spooling-0.2.10}/spooling/redact.py +0 -0
  53. {spooling-0.2.9 → spooling-0.2.10}/spooling/remote_otel.py +0 -0
  54. {spooling-0.2.9 → spooling-0.2.10}/spooling/schema.py +0 -0
  55. {spooling-0.2.9 → spooling-0.2.10}/spooling/sdk.py +0 -0
  56. {spooling-0.2.9 → spooling-0.2.10}/spooling/search.py +0 -0
  57. {spooling-0.2.9 → spooling-0.2.10}/spooling/server.py +0 -0
  58. {spooling-0.2.9 → spooling-0.2.10}/spooling/sessions.py +0 -0
  59. {spooling-0.2.9 → spooling-0.2.10}/spooling/stats.py +0 -0
  60. {spooling-0.2.9 → spooling-0.2.10}/spooling/subscription_pricing.py +0 -0
  61. {spooling-0.2.9 → spooling-0.2.10}/spooling/tracing.py +0 -0
  62. {spooling-0.2.9 → spooling-0.2.10}/spooling/tunnel.py +0 -0
  63. {spooling-0.2.9 → spooling-0.2.10}/spooling/tunnel_auth.py +0 -0
  64. {spooling-0.2.9 → spooling-0.2.10}/spooling/watcher.py +0 -0
  65. {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/dependency_links.txt +0 -0
  66. {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/entry_points.txt +0 -0
  67. {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/requires.txt +0 -0
  68. {spooling-0.2.9 → spooling-0.2.10}/spooling.egg-info/top_level.txt +0 -0
  69. {spooling-0.2.9 → spooling-0.2.10}/tests/test_claude_code_provider.py +0 -0
  70. {spooling-0.2.9 → spooling-0.2.10}/tests/test_mcp_auth_middleware.py +0 -0
  71. {spooling-0.2.9 → spooling-0.2.10}/tests/test_session_ids.py +0 -0
  72. {spooling-0.2.9 → spooling-0.2.10}/tests/test_storage_optimizations.py +0 -0
  73. {spooling-0.2.9 → spooling-0.2.10}/tests/test_tunnel_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.9
3
+ Version: 0.2.10
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.9"
3
+ version = "0.2.10"
4
4
  description = "Local session tracker and semantic search for AI coding assistants"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Parsed Analytics, Inc." }]
@@ -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 /v1/stats check failed: {e}[/red]")
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
- console.print(f"[red]Error: {e}[/red]")
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, str(e)
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, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
591
+ return total, rejected, _explain_http(e)
452
592
  except Exception as e:
453
- return total, rejected, str(e)
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
- console.print(f"[red]{err}[/red]")
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
- console.print(f"[yellow]Trace push failed: {t_err}[/yellow]")
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]HTTP {e.response.status_code}: {e.response.text[:200]}[/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, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
1072
+ return 0, 0, _explain_http(e)
932
1073
  except Exception as e:
933
- return 0, 0, str(e)
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]HTTP {e.response.status_code}: {e.response.text[:200]}[/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]HTTP {e.response.status_code}: {e.response.text[:200]}[/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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.9
3
+ Version: 0.2.10
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -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