Repository navigation
Give every language the same emit posture: retry, and never fail silently - #12
Merged
Merged
Conversation
…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 detectedLatest commit: bcd36c0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsongains aretryPolicyblock 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
maxAttempts3 → 7 in the corpus turns all five red:2. Python swallowed transport errors by default — BREAKING
swallow_errorsdefaulted toTrue. A misconfigured endpoint or an expired token dropped every audit event and reported success. Nothing raised, nothing logged unless you had separately passedon_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=Trueremains as an explicit opt-in, andon_errornow fires whether or not the error is swallowed, so a swallow is never silent.The old docstring also said:
That was never true — TypeScript's client has always thrown. Removed.
3. Async emit
Python gains
emit_async, running the blockingurllibPOST off the event loop withasyncio.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-returningEmitAsyncwould be less idiomatic, not more. What Go'sEmitgenuinely lacked was honouringctxcancellation between retries, which it now does (selectonctx.Done()vs the backoff timer, and a cancelled context is treated as final rather than transient).API additions
AuditClientOptions { max_retries: Option<usize>, retry_backoff_ms: Option<u64> }+AuditClientOptions::new(endpoint, token);DEFAULT_MAX_RETRIES/DEFAULT_RETRY_BACKOFF_MSexportedAuditClient.MaxRetries/.RetryBackoff(zero = default);DefaultMaxRetries/DefaultRetryBackoffAuditClientOptions.MaxRetries/.RetryBackoffMs;DefaultMaxRetries/DefaultRetryBackoffMsAuditClientOptions.max_retries/.retry_backoff_ms;AuditClient.emit_asyncRust needed
tokiofor the backoff sleep — feature-gated underclient, which already pulls tokio in through reqwest, so this adds a feature flag rather than a runtime.tests/emit_retry.rsis#![cfg(feature = "client")], soall_features_guard.rsgrows a matching arm — the same silent-skip trap the otel test fell into in #6, closed before it can happen twice.Test counts
format:check,version:check,tsc --noEmit,oxlint,clippy --all-targets --all-features -D warnings,ruff,basedpyright --level error,go vetall 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