Skip to content

Commit 372a1aa

Browse files
rustyconoverclaude
andcommitted
release: 0.25.0 — deployment controls for HTTP workers, and vgi-rpc 0.39.1
Four capabilities vgi-rpc had gained but VGI never exposed, plus the dependency floor that makes the last of them work. **`--max-externalized-response-bytes`** caps a single externalized response — the payload uploaded to blob storage and replaced on the wire by a pointer — so a worker can be deployed behind a load balancer, API gateway or object-store policy that will not carry an arbitrary body. Unlike `--max-stream-response-bytes`, which is soft for producer streams because a continuation token carries the overshoot to the next turn, this cap is hard on every method type with no continuation escape: bytes already uploaded cannot be un-uploaded. Plumbed through `create_app`, `vgi-serve`, `Worker.main --http`, the fixture server, and `export_serve_config` so granian children inherit it. Default stays no cap, and the header's *absence* is what tells a client there is no ceiling — a default value here would be a ceiling nobody chose. **`Worker.resolve_token()`** optionally exposes `POST {prefix}/__introspect_token__`, which resolves an opaque bearer credential to a principal for a reverse proxy that terminates the only public listener. The route does not exist until the hook is overridden — absent, not routed-and-refusing — detected by comparing against the base implementation rather than a flag, because a flag can be set without a lookup behind it. Enabling it requires `--introspect-principals` / `VGI_INTROSPECT_PRINCIPALS`, with no permissive default and a startup failure when it is missing: authenticating and introspecting are different capabilities, and "any authenticated caller" lets any user resolve any other user's credential to its owner. **`AuthUnavailableError`** is re-exported from `vgi.auth` and documented for custom authenticate callbacks generally, not just introspection. It is deliberately not a `ValueError`, which is the whole point: `chain_authenticate` advances on `ValueError`, so a sidecar outage raised as one reads as "not my credential, try the next" and ends up a 401 from the end of the chain — a thirty-second blip becoming a fleet-wide re-login storm. A test pins the non-subclassing, since that property is invisible at every call site that depends on it. **Access-log sampling and async emission** arrive as `--access-log-sample`, `--access-log-async` and `--access-log-queue-size` on `vgi-serve`, with `VGI_WORKER_ACCESS_LOG_*` equivalents resolved inside `configure_worker_logging` so every worker entry point honours them identically. One deliberate divergence from vgi-rpc's own implementation: the sampler goes on a handler dedicated to `vgi_rpc.access` rather than on the shared stderr handler. In VGI that handler also carries `vgi` and `vgi_rpc`, so sampling it would discard diagnostics at the same rate, and dropping half of a traceback is not a saving. A test asserts the sampler never reaches the diagnostic loggers. Trace correlation needed no knob at all — vgi_rpc reads whatever span is current at emit time, so `trace_id` / `span_id` appear as soon as `VGI_OTEL_ENABLED=1`. Floor raised to vgi-rpc 0.39.1 rather than 0.39.0: `--access-log-async` raised `RuntimeError: cannot set daemon status of active thread` at startup in every release that shipped it, so the option VGI now surfaces would have been dead on arrival against 0.39.0. Fixed upstream in Query-farm/vgi-rpc-python@27842b0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 54b77d1 commit 372a1aa

10 files changed

Lines changed: 844 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,11 @@ vgi-client --input data.parquet --function sum_all_columns --worker vgi-fixture-
297297
| `VGI_OAUTH_DEVICE_CODE_CLIENT_ID` | Client ID for device-code flow (optional, URL-safe chars only) |
298298
| `VGI_OAUTH_DEVICE_CODE_CLIENT_SECRET` | Client secret for device-code flow (optional, URL-safe chars only) |
299299
| `VGI_OAUTH_USE_ID_TOKEN` | When `1`/`true`/`yes`, clients use OIDC `id_token` as Bearer instead of `access_token` |
300+
| `VGI_INTROSPECT_PRINCIPALS` | Comma-separated principals permitted to call `__introspect_token__`. Required — no permissive default — whenever the worker implements `resolve_token()` (see below) |
301+
| `VGI_INTROSPECT_RATE_LIMIT` | Introspection requests allowed per caller per second (default 20) |
302+
| `VGI_WORKER_ACCESS_LOG_SAMPLE` | Fraction of *successful* calls to keep in the access log, `0.0``1.0`. Errors are always kept; the decision is per call, so every record of one stream shares a fate |
303+
| `VGI_WORKER_ACCESS_LOG_ASYNC` | When `1`/`true`/`yes`, emit access-log records from a listener thread. Bounded queue; full means drop, and a crash loses whatever is queued |
304+
| `VGI_WORKER_ACCESS_LOG_QUEUE_SIZE` | Bound on the async access-log queue (default 10000) |
300305
| `VGI_OTEL_ENABLED` | Enable OpenTelemetry instrumentation (`1`/`true`/`yes`) |
301306
| `VGI_OTEL_CUSTOM_ATTRIBUTES` | Comma-separated `key=value` pairs for custom span/metric attributes |
302307
| `VGI_OTEL_CLAIM_ATTRIBUTES` | Comma-separated `claim_key=span_attr_name` pairs for claim extraction |
@@ -392,6 +397,96 @@ Set `SENTRY_DSN` (and install `vgi[sentry]`) to forward unhandled exceptions to
392397

393398
The same enrichment applies to OTel spans when `VGI_OTEL_ENABLED=1` — both backends read from the same `VgiTracer.set_current_span_attributes()` call sites in `vgi/otel.py`. Either, neither, or both can be active in a process.
394399

400+
### Serving Behind a Load Balancer
401+
402+
`--max-externalized-response-bytes` caps a single *externalized* response — the
403+
payload uploaded to blob storage and replaced on the wire by a pointer. Set it
404+
to whatever the load balancer, API gateway, or object-store policy in front of
405+
the worker will actually carry.
406+
407+
```bash
408+
vgi-serve my.worker:MyWorker --http --max-externalized-response-bytes 67108864
409+
```
410+
411+
Unlike `--max-stream-response-bytes`, which governs the wire and is *soft* for
412+
producer streams (a continuation token carries the overshoot to the next turn),
413+
this cap is **hard on every method type with no continuation escape** — bytes
414+
already uploaded cannot be un-uploaded. Default is no cap. When set, the value
415+
is advertised as `VGI-Max-Externalized-Response-Bytes` so clients can size their
416+
own expectations.
417+
418+
Also available on `Worker.main --http` and the fixture server. Under
419+
`--server granian` it travels to worker processes through `export_serve_config`.
420+
421+
### Token Introspection (`resolve_token`)
422+
423+
A worker may optionally expose `POST {prefix}/__introspect_token__`, which
424+
resolves an opaque bearer credential to a principal — for a reverse proxy that
425+
terminates the only public listener and must know the caller's identity before
426+
it can authorize anything.
427+
428+
```python
429+
from vgi.auth import AuthUnavailableError, TokenIdentity
430+
from vgi.worker import Worker
431+
432+
class MyWorker(Worker):
433+
functions = [...]
434+
435+
@classmethod
436+
def resolve_token(cls, token: str) -> TokenIdentity | None:
437+
try:
438+
row = api_keys.lookup(token) # your own store
439+
except ConnectionError as exc:
440+
# "I could not find out" — 503 + Retry-After, not 401.
441+
raise AuthUnavailableError(str(exc)) from exc
442+
if row is None:
443+
return None # "the credential is unknown"
444+
return TokenIdentity(principal=row.principal, token_name=row.label)
445+
```
446+
447+
**The route does not exist until `resolve_token` is overridden** — absent, not
448+
routed-and-refusing. That is what keeps a dependency upgrade from growing a
449+
credential-to-identity oracle on every existing worker.
450+
451+
Enabling it also requires an allowlist of principals permitted to ask, via
452+
`--introspect-principals` or `VGI_INTROSPECT_PRINCIPALS`. There is no permissive
453+
default and a worker that overrides the hook without one **refuses to start**:
454+
authenticating and introspecting are different capabilities, and "any
455+
authenticated caller" lets any user resolve any other user's credential to its
456+
owner. `--introspect-rate-limit` (default 20/caller/second) bounds, rather than
457+
closes, the oracle an allowlisted-but-compromised caller still has.
458+
459+
Return `None` for "the store answered and this credential is unknown"; raise
460+
`AuthUnavailableError` for "the answer is not knowable". A caller that
461+
negative-caches the first must not cache the second. Never return claims — a
462+
pass-through claims field would let a worker choose its caller's tenant routing
463+
and policy branch.
464+
465+
`AuthUnavailableError` is worth reaching for in any custom `authenticate`
466+
callback too, not just here: it is deliberately **not** a `ValueError`, because
467+
`chain_authenticate` advances to the next authenticator on `ValueError` — so a
468+
sidecar outage raised as one is read as "not my credential, try the next" and
469+
ends up a 401 from the end of the chain, restarting every session in the fleet
470+
over a thirty-second blip.
471+
472+
### Access Log Sampling and Async Emission
473+
474+
`--access-log-sample 0.05` keeps 5% of *successful* calls. Errors are always
475+
kept, and the decision is made per call so every record belonging to one stream
476+
shares a fate. `--access-log-async` moves formatting and writing to a listener
477+
thread so disk latency stays off the request path; the queue is bounded
478+
(`--access-log-queue-size`, default 10000), full means drop, and the next record
479+
through carries `dropped_records` so a gap is never silent. A crash loses
480+
whatever is still queued — that is the trade.
481+
482+
Both apply only to `vgi_rpc.access`, which gets its own handler for the purpose.
483+
They are deliberately not applied to the diagnostic loggers: dropping half of a
484+
traceback is not a saving.
485+
486+
**Trace correlation needs no configuration.** vgi-rpc reads whatever span is
487+
current when it emits a record, so `trace_id` / `span_id` appear on every access
488+
record as soon as `VGI_OTEL_ENABLED=1`.
489+
395490
### Key Constraints for Scalar Functions:
396491
- **1:1 row mapping**: Output must have exactly the same number of rows as input
397492
- **Single column output**: Output schema has exactly one column named "result"

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vgi-python"
3-
version = "0.24.0"
3+
version = "0.25.0"
44
description = "Vector Gateway Interface - Connect DuckDB to external programs via Apache Arrow"
55
readme = "README.md"
66
keywords = [
@@ -40,7 +40,7 @@ dependencies = [
4040
"pyarrow",
4141
"typer>=0.9",
4242
"platformdirs",
43-
"vgi-rpc>=0.33.0",
43+
"vgi-rpc>=0.39.1",
4444
"httpx>=0.24",
4545
]
4646

tests/test_serve.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,203 @@ def test_signing_key_passed(self) -> None:
270270
assert isinstance(app, falcon.App)
271271

272272

273+
def _capability_headers(app: object) -> dict[str, str]:
274+
"""Read the capability headers a served app advertises on ``/health``.
275+
276+
Args:
277+
app: The Falcon app under test.
278+
279+
Returns:
280+
Response headers, lowercased.
281+
282+
"""
283+
import falcon.testing
284+
285+
resp = falcon.testing.TestClient(app).simulate_get("/vgi/health")
286+
assert resp.status_code == 200
287+
return {k.lower(): v for k, v in resp.headers.items()}
288+
289+
290+
class TestMaxExternalizedResponseBytes:
291+
"""The externalized-response cap, for workers behind a size-limited proxy."""
292+
293+
def test_advertised_when_set(self) -> None:
294+
"""The cap reaches the wire, so a client can size its own expectations."""
295+
app = create_app(_SingleWorker, prefix="/vgi", describe=False, max_externalized_response_bytes=65536)
296+
assert _capability_headers(app)["vgi-max-externalized-response-bytes"] == "65536"
297+
298+
def test_absent_by_default(self) -> None:
299+
"""No cap unless one is configured.
300+
301+
The header's absence is what tells a client there is no ceiling; a
302+
default value here would be a ceiling nobody chose.
303+
"""
304+
app = create_app(_SingleWorker, prefix="/vgi", describe=False)
305+
assert "vgi-max-externalized-response-bytes" not in _capability_headers(app)
306+
307+
308+
class _IntrospectingWorker(Worker):
309+
"""Worker that implements the optional token-introspection hook."""
310+
311+
functions = [_DoubleFunc]
312+
313+
@classmethod
314+
def resolve_token(cls, token: str) -> object | None:
315+
from vgi.auth import TokenIdentity
316+
317+
if token == "good-token":
318+
return TokenIdentity(principal="alice", token_name="deploy-key-1")
319+
return None
320+
321+
322+
class TestIntrospectResolverDetection:
323+
"""``resolve_token`` presence is detected by override, not by a flag."""
324+
325+
def test_base_worker_has_no_resolver(self) -> None:
326+
"""A worker that never wrote the lookup gets no endpoint."""
327+
assert _SingleWorker._introspect_resolver() is None
328+
329+
def test_override_is_detected(self) -> None:
330+
"""Implementing the hook is what enables it."""
331+
resolver = _IntrospectingWorker._introspect_resolver()
332+
assert resolver is not None
333+
assert resolver("good-token") is not None
334+
assert resolver("nope") is None
335+
336+
337+
class TestTokenIntrospection:
338+
"""``POST /__introspect_token__`` — absent unless a worker implements it."""
339+
340+
@staticmethod
341+
def _client(worker_cls: type[Worker], **env: str) -> object:
342+
import falcon.testing
343+
344+
from vgi.serve import _resolve_authenticate
345+
346+
with pytest.MonkeyPatch.context() as mp:
347+
for key in ("VGI_INTROSPECT_PRINCIPALS", "VGI_INTROSPECT_RATE_LIMIT", "VGI_BEARER_TOKENS"):
348+
mp.delenv(key, raising=False)
349+
for key, value in env.items():
350+
mp.setenv(key, value)
351+
app = create_app(
352+
worker_cls,
353+
prefix="/vgi",
354+
describe=False,
355+
authenticate=_resolve_authenticate(),
356+
)
357+
return falcon.testing.TestClient(app)
358+
359+
def test_route_absent_without_hook(self) -> None:
360+
"""Not "routed and refusing" — absent.
361+
362+
This is the property that keeps a dependency upgrade from silently
363+
growing a credential-to-identity oracle on every existing worker.
364+
"""
365+
client = self._client(_SingleWorker)
366+
resp = client.simulate_post("/vgi/__introspect_token__", json={"token": "x"}) # type: ignore[attr-defined]
367+
assert resp.status_code == 404
368+
369+
def test_capability_not_advertised_without_hook(self) -> None:
370+
"""A proxy preflighting at boot learns this worker cannot answer."""
371+
app = create_app(_SingleWorker, prefix="/vgi", describe=False)
372+
assert "vgi-token-introspection" not in _capability_headers(app)
373+
374+
def test_capability_advertised_with_hook(self) -> None:
375+
"""A proxy can discover support at boot rather than at first login."""
376+
with pytest.MonkeyPatch.context() as mp:
377+
mp.setenv("VGI_INTROSPECT_PRINCIPALS", "proxy-a")
378+
app = create_app(_IntrospectingWorker, prefix="/vgi", describe=False)
379+
assert _capability_headers(app)["vgi-token-introspection"] == "true"
380+
381+
def test_allowlisted_caller_resolves(self) -> None:
382+
"""The endpoint answers with a principal and never with claims."""
383+
client = self._client(
384+
_IntrospectingWorker,
385+
VGI_INTROSPECT_PRINCIPALS="proxy-a",
386+
VGI_BEARER_TOKENS="ptok=proxy-a",
387+
)
388+
resp = client.simulate_post( # type: ignore[attr-defined]
389+
"/vgi/__introspect_token__",
390+
json={"token": "good-token"},
391+
headers={"Authorization": "Bearer ptok"},
392+
)
393+
assert resp.status_code == 200
394+
assert resp.json == {"principal": "alice", "token_name": "deploy-key-1", "ttl_seconds": 300}
395+
assert "claims" not in resp.json
396+
397+
def test_unresolvable_token_is_404(self) -> None:
398+
"""Unknown credential and unknown route answer alike, on purpose."""
399+
client = self._client(
400+
_IntrospectingWorker,
401+
VGI_INTROSPECT_PRINCIPALS="proxy-a",
402+
VGI_BEARER_TOKENS="ptok=proxy-a",
403+
)
404+
resp = client.simulate_post( # type: ignore[attr-defined]
405+
"/vgi/__introspect_token__",
406+
json={"token": "nope"},
407+
headers={"Authorization": "Bearer ptok"},
408+
)
409+
assert resp.status_code == 404
410+
411+
def test_non_allowlisted_caller_refused(self) -> None:
412+
"""Authenticating is not the same capability as introspecting.
413+
414+
Without this, any user holding a valid credential could resolve any
415+
other user's credential to its owner.
416+
"""
417+
client = self._client(
418+
_IntrospectingWorker,
419+
VGI_INTROSPECT_PRINCIPALS="proxy-a",
420+
VGI_BEARER_TOKENS="ptok=proxy-a,utok=user-b",
421+
)
422+
resp = client.simulate_post( # type: ignore[attr-defined]
423+
"/vgi/__introspect_token__",
424+
json={"token": "good-token"},
425+
headers={"Authorization": "Bearer utok"},
426+
)
427+
assert resp.status_code == 403
428+
429+
def test_hook_without_allowlist_refuses_to_start(self) -> None:
430+
"""Fail closed and loud, rather than defaulting to an open oracle."""
431+
with pytest.MonkeyPatch.context() as mp:
432+
mp.delenv("VGI_INTROSPECT_PRINCIPALS", raising=False)
433+
with pytest.raises(SystemExit) as exc:
434+
create_app(_IntrospectingWorker, prefix="/vgi", describe=False)
435+
assert exc.value.code == 1
436+
437+
@pytest.mark.parametrize("bad", ["nonsense", "0", "-5"])
438+
def test_bad_rate_limit_refuses_to_start(self, bad: str) -> None:
439+
"""A typo must not silently become "refuse everything" or "no bound"."""
440+
with pytest.MonkeyPatch.context() as mp:
441+
mp.setenv("VGI_INTROSPECT_PRINCIPALS", "proxy-a")
442+
mp.setenv("VGI_INTROSPECT_RATE_LIMIT", bad)
443+
with pytest.raises(SystemExit):
444+
create_app(_IntrospectingWorker, prefix="/vgi", describe=False)
445+
446+
447+
class TestAuthUnavailableReExport:
448+
"""``AuthUnavailableError`` is reachable without naming a vgi_rpc module."""
449+
450+
def test_importable_from_vgi_auth(self) -> None:
451+
"""Worker authors reach it from ``vgi.auth`` like every other auth type."""
452+
from vgi_rpc.http import AuthUnavailableError as Upstream
453+
454+
from vgi.auth import AuthUnavailableError
455+
456+
assert AuthUnavailableError is Upstream
457+
458+
def test_is_not_a_value_error(self) -> None:
459+
"""The whole point: ``chain_authenticate`` must not swallow it.
460+
461+
The chain advances to the next authenticator on ``ValueError``, so an
462+
outage raised as one is read as "not my credential, try the next" and
463+
ends up a 401 — restarting every session in the fleet over a blip.
464+
"""
465+
from vgi.auth import AuthUnavailableError
466+
467+
assert not issubclass(AuthUnavailableError, ValueError)
468+
469+
273470
# ---------------------------------------------------------------------------
274471
# Tests: env var helpers
275472
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)