Skip to content

Give every language the same emit posture: retry, and never fail silently - #12

Merged
brentrager merged 1 commit into
mainfrom
fix/emit-parity
Aug 20, 2026
Merged

brentrager merged 1 commit into
mainfrom
fix/emit-parity

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

The P2 surface asymmetries around emit. Each one sits on the path that carries the audit record.

1. Retry was TypeScript-only

A single un-retried POST turns one transient blip into a permanent gap in a tamper-evident log — a hole that verification cannot even see, because a chain of what did arrive verifies perfectly.

Python, Rust, Go, and .NET now retry transport errors and HTTP 5xx with exponential backoff. A 4xx fails fast, because it will say the same thing on the next attempt; burning the remaining attempts on it is not caution, it's latency. The retried request carries the same canonical bytes — ingest dedupes on the event hash, so a re-serialized preimage would defeat the dedupe.

The defaults are not restated five times. spec/parity-corpus.json gains a retryPolicy block and each language's suite asserts its own client against it:

{ "maxAttempts": 3, "baseBackoffMs": 100, "backoffMultiplier": 2,
  "retryOn": ["transport_error", "http_5xx"], "failFastOn": ["http_4xx"] }

Verified it actually bites — changing maxAttempts 3 → 7 in the corpus turns all five red:

TS 2 failed | Python 2 failed | Rust 2 failed | Go 2 failed | .NET 2 failed

2. Python swallowed transport errors by default — BREAKING

swallow_errors defaulted to True. A misconfigured endpoint or an expired token dropped every audit event and reported success. Nothing raised, nothing logged unless you had separately passed on_error. The gap stayed invisible until someone went looking for a trail that was never written.

That is fail-open on the exact field carrying the conclusion. It now defaults to False. swallow_errors=True remains as an explicit opt-in, and on_error now fires whether or not the error is swallowed, so a swallow is never silent.

The old docstring also said:

Like the TS emitter, errors are swallowed by default

That was never true — TypeScript's client has always thrown. Removed.

3. Async emit

Python gains emit_async, running the blocking urllib POST off the event loop with asyncio.to_thread. Deliberately not a second HTTP transport: this SDK has no HTTP dependency by design, and adding one just to have an async path would be a second thing to keep in parity.

Go was NOT missing async — correcting that finding. go client.Emit(ctx, event) is how Go does async; a callback-based or channel-returning EmitAsync would be less idiomatic, not more. What Go's Emit genuinely lacked was honouring ctx cancellation between retries, which it now does (select on ctx.Done() vs the backoff timer, and a cancelled context is treated as final rather than transient).

API additions

Rust AuditClientOptions { max_retries: Option<usize>, retry_backoff_ms: Option<u64> } + AuditClientOptions::new(endpoint, token); DEFAULT_MAX_RETRIES / DEFAULT_RETRY_BACKOFF_MS exported
Go AuditClient.MaxRetries / .RetryBackoff (zero = default); DefaultMaxRetries / DefaultRetryBackoff
.NET AuditClientOptions.MaxRetries / .RetryBackoffMs; DefaultMaxRetries / DefaultRetryBackoffMs
Python AuditClientOptions.max_retries / .retry_backoff_ms; AuditClient.emit_async

Rust needed tokio for the backoff sleep — feature-gated under client, which already pulls tokio in through reqwest, so this adds a feature flag rather than a runtime.

tests/emit_retry.rs is #![cfg(feature = "client")], so all_features_guard.rs grows a matching arm — the same silent-skip trap the otel test fell into in #6, closed before it can happen twice.

Test counts

before after
TypeScript 51 54
Python 63 68
Rust 21 25
Go 9 13
.NET 31 34

format:check, version:check, tsc --noEmit, oxlint, clippy --all-targets --all-features -D warnings, ruff, basedpyright --level error, go vet all clean.

README per-language matrix updated. Minor changeset included (it flags the Python default change as breaking in the body).

🤖 Generated with Claude Code

https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC

…ntly

Three asymmetries around emit, all on the path that carries the audit record.

RETRY was TypeScript-only. An audit event that silently fails to emit is a hole
in the record, and a single un-retried POST turns one transient blip into a
permanent gap in a tamper-evident log. Python, Rust, Go and .NET now retry
transport errors and HTTP 5xx with exponential backoff; a 4xx is surfaced
immediately, because it will say the same thing on the next attempt. The
retried request carries the SAME canonical bytes — ingest dedupes on the event
hash, so a re-serialized preimage would defeat that.

The defaults are not restated five times. spec/parity-corpus.json gains a
retryPolicy block (3 attempts, 100ms base, x2) and every language's suite
asserts its own client against it. Control: changing maxAttempts 3 -> 7 in the
corpus turns all five suites RED (TS 2, Python 2, Rust 2, Go 2, .NET 2).

PYTHON SWALLOWED TRANSPORT ERRORS BY DEFAULT. swallow_errors defaulted to True,
so a misconfigured endpoint or an expired token dropped every event and
reported success — the gap invisible until someone went looking for a trail
that was never written. That is fail-open on exactly the field that carries the
conclusion. It now defaults to False; swallow_errors=True is an explicit
opt-in, and on_error fires either way so a swallow is never silent.

The docstring also claimed this matched the TS emitter. It never did — TS has
always thrown. Removed.

ASYNC: Python gains emit_async, running the blocking urllib POST off the loop
with asyncio.to_thread rather than adding a second transport to keep in parity.

Go was NOT missing async — `go client.Emit(ctx, event)` is how Go does async,
and a callback/goroutine-returning API would be less idiomatic, not more.
Corrected rather than "fixed"; Emit now also honours ctx cancellation between
retries, which is the thing it actually lacked.

tests/emit_retry.rs is #![cfg(feature = "client")], so all_features_guard.rs
grows a matching arm — the same silent-skip trap the otel test fell into.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bcd36c0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/audit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager merged commit ccc4f4e into main Aug 20, 2026
1 check passed
@brentrager
brentrager deleted the fix/emit-parity branch August 20, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant