fix(router): abort router-transform on proactive invalid_grant instead of retrying (INT-6779) - #7175
Open
shekhar-rudder wants to merge 7 commits into
Conversation
invalid_grant is a permanent user error (revoked token), not a control-plane health signal, so it must not trip the per-account OAuth error breaker or fire the false 'Control plane issue' alert (INT-6779).
… of retrying A proactive FetchToken that fails with invalid_grant returned a raw 400 body that the router-transform non-200 handler collapsed to a retryable 500, causing ~3h of retries and a 410 drain. preRoundTrip now wraps invalid_grant in the interceptor envelope and the transform handler honors that terminal status, so the job aborts at 400 (INT-6779). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ant" This reverts commit 5cad450.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #7175 +/- ##
==========================================
+ Coverage 80.01% 80.30% +0.28%
==========================================
Files 595 600 +5
Lines 66133 67034 +901
==========================================
+ Hits 52915 53829 +914
+ Misses 10071 10044 -27
- Partials 3147 3161 +14 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
saikumarrs
approved these changes
Jul 21, 2026
The `errors.Is(scErr, common.ErrInvalidGrant)` check was not load-bearing: FetchToken can only ever return 400 or 500, and 400 is produced exclusively by ref_token_invalid_grant (getRefreshTokenFromResponse starts at 500 and only the RefTokenInvalidGrant case sets 400). The status code already carried the signal the check was re-deriving. What actually makes the job abort is the response *body*: router transformation ignores the raw HTTP status and collapses every non-200 to a retryable 500, so the real status has to travel in the interceptor envelope. Always wrap the FetchToken failure in the envelope and populate OriginalResponse with the raw error text, so the callers that fall back to it (transformer proxy, regulation worker, yandexmetrica, salesforce bulk upload) keep the readable message they had before the envelope existed, instead of surfacing the JSON envelope itself as the error. Adds transport-level coverage for both the terminal (400) and retryable (500) proactive fetch failures, and corrects the transformer comment: the `< 500` gate also admits the authStatus-inactive 400, which is likewise terminal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the comment-only rewrite from the previous commit. No logic change in either direction; the `< 500` gate, the transport envelope and the new transport-level tests are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The router transform handler gated the abort on the interceptor status being < 500, which is broader than intended: postRoundTrip also reports AUTH_STATUS_INACTIVE as a terminal 400, so a non-200 transform response carrying that category aborted where it previously retried. Carry the specific failure in the envelope instead. OAuthInterceptorResponse gains an ErrorType field, set from the underlying TypeMessageError, and the handler aborts only when it is ref_token_invalid_grant. Every other interceptor outcome - terminal-looking or not - keeps the retryable path and its original message. Adds a router-level regression test (a non-200 transform response carrying AUTH_STATUS_INACTIVE must stay 500), which fails under the old < 500 gate, and extends the transport table with a non-invalid_grant error type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… status The gate also required a non-zero interceptor status and reused it as the job status. That was unreachable defence against a lenient unmarshal, and it made the abort depend on a status the error type already implies. invalid_grant means the refresh token has been revoked, which is terminal however it arrives, so the type alone now decides and the job is aborted with a 400. This also fixes a reachable gap: getRefreshTokenFromResponse reports the same error type with a 500 when the control plane returns it via the top-level errorType key, which previously retried for the full window. Adds a router-level test for that path; it fails under the previous gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Description
Related to INT-6779.
When an OAuth destination's refresh token is revoked/expired, rudder-auth returns
ref_token_invalid_grant. During router transformation, a proactiveFetchTokenthat fails this way was returned by the OAuth transport'spreRoundTripas a raw-text HTTP 400 body. The router-transform response handler collapses any non-200 (non-404) response to a hardcoded500(retryable), so the real400was discarded — the job retried for the full retry window (~3h) and was ultimately drained at410instead of aborting.This PR makes
ref_token_invalid_grantabort at 400 in the router-transform flow.services/oauth/v2/http/transport.go—preRoundTripnow reports a failedFetchTokenthrough theTransportResponse/InterceptorResponseenvelope, carrying the real status code and a clean message.OriginalResponsekeeps the raw error text, so every caller that falls back to it is unaffected.services/oauth/v2/types.go—OAuthInterceptorResponsegains anErrorTypefield, so callers act on the specific failure instead of inferring terminality from the status code.router/transformer/transformer.go— the non-200 else-branch aborts with a400only when the interceptor reportsref_token_invalid_grant. The error type alone decides; the status the interceptor attached is not consulted, since a revoked refresh token is terminal however it arrives. Every other interceptor outcome keeps the existing retryable path and its original message.Why there is no explicit
invalid_grantcheckAn earlier revision gated the envelope on
errors.Is(err, common.ErrInvalidGrant). That check was redundant and has been removed:FetchTokencan only ever return400or500, and400is produced exclusively byref_token_invalid_grant—getRefreshTokenFromResponseinitialisesstatusCodeto500and only thecommon.RefTokenInvalidGrantcase sets400; every other failure path isNewStatusCodeError(http.StatusInternalServerError, ...). The status code already carries the signal the check was re-deriving.What actually fixes the bug is the response body, not the gate: router transformation ignores the raw HTTP status, so the real status has to travel inside the interceptor envelope for the handler to see it.
An earlier revision of this PR gated the abort on the interceptor status being
< 500. That was broader than intended:postRoundTripalso reportsAUTH_STATUS_INACTIVEas a terminal400, so a non-200 transform response carrying that category would have aborted where it previously retried. The gate is now keyed onErrorType == common.RefTokenInvalidGrantalone.Keying on the type rather than the status also closes a reachable gap:
getRefreshTokenFromResponsehas an early branch on a top-levelerrorTypekey that hardcodes a500, so a control plane reportingref_token_invalid_grantthat way produced a retryable500and burned the full retry window. It now aborts like any otherinvalid_grant.No change to the delivery/proxy flow — its reactive
RefreshToken→400abort already worked correctly.Scope note
An earlier revision of this PR also excluded
invalid_grantfrom the per-account OAuth circuit breaker. That has been reverted: we're keepinginvalid_grantcounting toward the breaker (it preserves control-plane protection). The circuit-breaker / alerting side of INT-6779 — detecting a control-plane bug that erroneously returnsinvalid_grant— will be handled separately via a multi-account breaker-trip alert (fire when the breaker trips across many distinct accounts, which distinguishes a CP-wide problem from isolated token revocations), tracked as a follow-up.Behavior change
For a revoked-token OAuth destination (e.g. LINKEDIN_ADS): jobs abort immediately with
400and a "please reauthorize" message — no 3-hour retry storm, no410drain. Transient/other failures (empty secret, network, non-invalid_granterrors →500) still retry, unchanged.Testing
TestRouterTransformationWithOAuthV2:invalid_grant→ jobs abort at400with the clean message;invalid_grantproactive failure (empty secret) stays500(retryable);TestRouterTransformationWithOAuthV2(new regression case):AUTH_STATUS_INACTIVE(a terminal400that is notinvalid_grant) stays500and is retried. This case fails under the old< 500gate;invalid_grantreported via the top-levelerrorTypekey (status500) still aborts at400. This case fails under the earlier status-honouring gate.services/oauth/v2/http(new, table-driven at the transport level):invalid_grant→400, taggedErrorType: ref_token_invalid_grant, clean message inInterceptorResponse.Response, raw text preserved inOriginalResponse;INVALID_REFRESH_RESPONSE→500, tagged with its own error type;500, emptyErrorType,OriginalResponseunchanged from the pre-envelope raw body.Also verified green (these consume the same transport and see the changed body):
regulation-worker/internal/delete/api,yandexmetrica,salesforce-bulk-upload,services/oauth/v2.Known review findings
Open, not addressed here — flagging for reviewer input:
invalid_grantstill counts toward the per-account error breaker, once it trips (5 consecutive failures)withErrBreakerreturns the cachedlastErrorfor 5 minutes without calling the control plane (services/oauth/v2/oauth_breaker.go:191). That stale error still carries the terminal400, so for that window every job on the account aborts on the strength of one old CP response. Given INT-6779's premise is that the CP can erroneously returninvalid_grant, it may be worth putting the abort behind a config var so it can be disabled without a rollback.🤖 Generated with Claude Code