Skip to content

Meet the remaining TAPI HTTP agreements: Authorization header, 511, generic Retry-After (529) - #148

Open
MichaelGHSeg wants to merge 6 commits into
csharp-public-httpconfigfrom
csharp-529-retry-after
Open

MichaelGHSeg wants to merge 6 commits into
csharp-public-httpconfigfrom
csharp-529-retry-after

Conversation

@MichaelGHSeg

@MichaelGHSeg MichaelGHSeg commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #147 — review that first. This PR has grown past its original scope: it now closes the three remaining TAPI Key Agreements this SDK did not meet, not just 529.

Authorization header — the headline change

The design doc's first Key Agreement is that every SDK sends the write key in the Authorization header. TAPI authenticates and routes on that header rather than parsing the payload, which is the server-side performance reason the header exists.

This SDK sent no Authorization at all. It relied solely on the writeKey that Storage embeds in the batch body, so it was giving up the benefit the whole initiative was for.

Upload requests now carry Basic base64("<writeKey>:") — an empty password, matching analytics-python, -go, -ruby, -php and -java. The value is exposed as a protected HTTPClient.BasicAuthorization rather than by widening _apiKey, so a custom IHTTPClientProvider can send it too; the Unity sample, which overrides DoPost, does. The writeKey stays in the payload, so nothing depends on the header alone yet.

Note for reviewers: adding this header is what broke some customers' proxy CORS allowlists when analytics-next browser shipped it. Worth calling out in the release notes.

511 Network Authentication Required

The doc makes 511 conditional — "Authenticate, then retry if library supports OAuth." This SDK has no OAuth, so a 511 could never be satisfied and retrying only spent the budget. It joins 501 and 505 as an explicit Drop. (analytics-python is the only SDK with OAuth and correctly retries 511 only when an OauthManager is configured.)

Generic Retry-After, including 529

Any retryable response carrying a valid Retry-After routes through the rate-limit path, which does not consume retry budget; retryable statuses without the header continue on counted exponential backoff. Adds RetryAfterParser handling delta-seconds and RFC 1123 HTTP-date (null for past dates). Matches analytics-java 3.5.5 and the generic-retry-after conformance suite.

Two follow-up commits fix a data-loss bug this introduced: routing a retryable status to the rate-limit path left ShouldDeleteBatch inconsistent with HandleResponse, so with rate limiting on and backoff off a 503/529 carrying Retry-After would rate-limit the pipeline and delete the batch, then stall waiting to retry events that no longer existed. ShouldDeleteBatch now takes the same retryAfterSeconds value as HandleResponse so the two agree by construction. The first attempt at that fix was too broad and the backoffConfig.enabled: false e2e case caught it.

3xx as success

Analytics-CSharp-plan.md states "Spec item 1: 2xx and 3xx are success", but IsSuccessStatusCode and the RetryStateMachine checks were 2xx-only. Brought in line with the plan and with analytics-go, -python and -php.

Testing

  • 248 unit tests pass on the combined branch (this PR plus Expose HttpConfig so retry behaviour is user-configurable #147), including new coverage for the auth header value, 511, and the keep-or-delete invariant.
  • 82 e2e tests pass across 12 files — the most of any SDK, since this is the only one running all four retry-settings suites.
  • The e2e Authorization assertion is new (sdk-e2e-tests branch test/header-agreements) and is what caught the missing header here. It is opt-in per SDK via "AUTH_HEADER": "true", because analytics-kotlin and analytics-swift deliberately do not send it yet pending the v2 endpoint discussion. This SDK opts in.

E2E was run locally because CI cannot check out the private sdk-e2e-tests repo — the token: ${{ secrets.E2E_TESTS_TOKEN }} line was dropped from that checkout during CI hardening, and the same omission is present in this repo's ci/harden-build-publish branch and in go/ruby/php.

Before release

Analytics-CSharp.csproj still says <Version>2.6.0</Version>, which is already published (2025-12-04). It needs a bump.

Route any retryable response carrying a valid Retry-After header through the
rate-limit path (no retry-budget cost) instead of special-casing 429. Retryable
statuses without Retry-After continue to use counted exponential backoff. Adds
529 to the retryable set and covers both paths with tests.

Matches the behaviour already shipped in analytics-java 3.5.5 and the
generic-retry-after conformance suite in sdk-e2e-tests.
Routing any retryable status with Retry-After to the rate-limit path left
ShouldDeleteBatch inconsistent with HandleResponse. With rate limiting on and
backoff off, a 503 or 529 carrying Retry-After would rate-limit the pipeline
(WaitUntilTime set, uploads blocked) while ShouldDeleteBatch still reported
true, so the batch file was deleted and the pipeline then stalled waiting to
retry events that no longer existed.

That configuration is reachable from CDN settings and directly from
Configuration.HttpConfig — it is the config ConfigurationHttpConfigTest builds.

ShouldDeleteBatch now keeps a retryable batch whenever rate limiting is
enabled, matching swift's shouldDropBatch ("Rate limit config handles retryable
codes that carry Retry-After — don't drop"). Non-retryable statuses are still
dropped, and a retryable status with neither rate limiting nor backoff enabled
is still dropped since nothing would retry it.
The previous commit kept a retryable batch whenever rate limiting was enabled,
which was too broad: a 500 with no Retry-After and backoff disabled was also
kept, so the file was re-uploaded even though nothing had scheduled a retry.
The sdk-e2e-tests "backoffConfig.enabled: false" case caught this — it expects
exactly one request and saw two.

ShouldDeleteBatch now takes the same retryAfterSeconds value handed to
HandleResponse, so the two agree on whether the response actually took the
rate-limit path. A retryable status keeps its batch only when it carries a
usable Retry-After and rate limiting is on; otherwise only backoff can retry
it, and with backoff off the batch is dropped as before. The single-argument
overload is retained.

232 tests pass.
Analytics-CSharp-plan.md states 'Spec item 1: 2xx and 3xx are success', but
IsSuccessStatusCode and the two status checks in RetryStateMachine were
2xx-only, so a 3xx fell through to the retry classifier. analytics-go,
analytics-python and analytics-php already follow the spec here; this brings C#
into line with them and with its own plan.

236 tests pass, including new cases covering 200, 201, 301 and 304.
Two Key Agreements from the HTTP response design doc that this SDK did not meet.

The doc requires every SDK to send the write key in the Authorization header,
and TAPI authenticates and routes on it instead of parsing the payload — which
is the performance reason the header exists. This SDK sent no Authorization at
all; it relied solely on the writeKey embedded in the batch body by Storage.
Upload requests now carry Basic credentials built from the write key with an
empty password, matching analytics-python, -go, -ruby, -php and -java, all of
which send base64("<writeKey>:").

The value is exposed as a protected BasicAuthorization on HTTPClient rather
than by widening _apiKey, so a custom IHTTPClientProvider can send the same
header; the Unity sample, which overrides DoPost, now does. The writeKey stays
in the payload, so nothing depends on the header alone yet.

Separately, 511 Network Authentication Required was retryable here. The doc
makes it conditional — "Authenticate, then retry if library supports OAuth" —
and this SDK has no OAuth, so a 511 could never be satisfied and retrying only
spent the budget. It joins 501 and 505 as an explicit Drop. analytics-python,
the one SDK with OAuth, correctly retries 511 only when an OauthManager is
configured; go, ruby, php and java exclude it as this now does.

240 tests pass, including new coverage of the header value, and all 79 e2e
tests still pass.
The header assertion in sdk-e2e-tests is opt-in per SDK, since analytics-kotlin
and analytics-swift do not send it yet. This SDK does, so it runs the check.
@MichaelGHSeg MichaelGHSeg changed the title Handle Retry-After on every retryable status, including 529 Meet the remaining TAPI HTTP agreements: Authorization header, 511, generic Retry-After (529) Sep 17, 2026
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