Skip to content

fix: retry refresh token updates on serialization failures (SQL & ent)#4830

Open
RonnanSouza wants to merge 1 commit into
dexidp:masterfrom
RonnanSouza:fix/sql-retry-serialization-failure
Open

fix: retry refresh token updates on serialization failures (SQL & ent)#4830
RonnanSouza wants to merge 1 commit into
dexidp:masterfrom
RonnanSouza:fix/sql-retry-serialization-failure

Conversation

@RonnanSouza

@RonnanSouza RonnanSouza commented Jun 11, 2026

Copy link
Copy Markdown

Overview

When several clients call UpdateRefreshToken — which performs a read-modify-write inside a SERIALIZABLE transaction — concurrent rotation of the same token makes the database abort one transaction with a serialization failure, e.g.:

time=2026-06-10T09:34:44.629Z level=ERROR msg="failed to update refresh token" err="update refresh token: pq: could not serialize access due to concurrent update" ​

Neither SQL storage backend retried, so the abort surfaced to clients as a 500.

What this PR does / why we need it

Add a bounded, jittered retry around the refresh-token update in both SQL backends (storage/sql and storage/ent):

  • Up to 8 retries with jittered backoff; each attempt re-runs the transaction in a fresh read-modify-write, so it's safe.
  • Per-driver detection of transient failures via errors.As: Postgres 40001 / 40P01, MySQL 1213 / 1205.
  • The retry is an application-level wrapper in both backends, since neither Go's database/sql nor ent retries transactions natively.
  • Re-enables the refresh-token concurrency conformance tests for Postgres / MySQL (and MySQL 8 for ent).

Special notes for your reviewer

  • I've decided to set reasonable hard coded values for the retries and backoff, but we can expose to be configurable if you folks think it is necessary
  • Also, I've done this fix only for update refresh token as it is where I faced the issue and I think where the API is more vulnerable, but I can port to cover all the endpoints

@RonnanSouza RonnanSouza force-pushed the fix/sql-retry-serialization-failure branch 3 times, most recently from cf70e72 to 1f86e6a Compare June 12, 2026 13:21
UpdateRefreshToken runs a read-modify-write inside a SERIALIZABLE
transaction. Under concurrent refresh-token rotation of the same token
(e.g. multiple kube clients refreshing at once), Postgres aborts the
conflicting transaction with SQLSTATE 40001 and the error surfaced to
clients as HTTP 500.

Add a bounded, jittered retry around the refresh-token update that
re-runs the transaction on transient serialization/deadlock failures,
detected per-driver (Postgres 40001/40P01, MySQL 1213/1205). Error
wraps in the SQL storage now use %w so the driver error can be matched
with errors.As. SQLite serializes writes and opts out (nil check).

Enables the previously-disabled refresh-token concurrency conformance
tests for Postgres and MySQL.

Signed-off-by: Ronan <ronanpalmeiras@gmail.com>

fix(storage/ent): retry refresh token update on serialization failures

Mirror the SQL backend fix in the ent storage. UpdateRefreshToken runs
in a SERIALIZABLE transaction and aborts with a serialization failure
under concurrent rotation of the same token, surfacing as HTTP 500.

Wrap the refresh-token update in a bounded, jittered retry that re-runs
the transaction on transient serialization/deadlock failures, detected
per-driver (Postgres 40001/40P01, MySQL 1213/1205) via errors.As over
the ent-wrapped driver error.

Enables the previously-disabled refresh-token concurrency conformance
tests for Postgres, MySQL and MySQL 8.

Signed-off-by: Ronan <ronanpalmeiras@gmail.com>

refactor(storage): extract shared SQL retry policy into sqlretry

refactor(storage): extract shared SQL retry policy into sqlretry

Signed-off-by: Ronan <ronanpalmeiras@gmail.com>
@RonnanSouza RonnanSouza force-pushed the fix/sql-retry-serialization-failure branch from 1f86e6a to a016e56 Compare June 12, 2026 13:23
Comment thread storage/sql/crud.go
if err != nil {
return err
}
if r, err = updater(r); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running updater(r) inside a retry can be unsafe in many scenarios... The least I would require for a feature like this would be to make it opt-in

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running updater(r) inside a retry can be unsafe in many scenarios... The least I would require for a feature like this would be to make it opt-in

I don't see how this can be unsafe, if the it re-opens a fresh transaction and re-reads state each attempt. Also, I couldn't think of a scenario where a straight 500 is better than a retry with a reasonable timeout. But if you folks thinks this is necessary to have it merged, I'm happy to adapt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running updater(r) multiple times can have all sorts of consequences depending on the connector being used. This introduces a large pool of failure modes.

  • For OIDC connectors backed by providers with refresh-token rotation, the first attempt can successfully exchange the upstream refresh token and receive a new one, then lose the SQL transaction. The
    retry can call the provider again with the old upstream refresh token. Providers like Okta may reject that as reuse, and may invalidate the newly issued token chain.

  • The updater mutates captured state outside the transaction (newToken, lastUsed, ident, rerr). If attempt 1 reaches the connector and attempt 2 takes a different branch after re-reading the DB
    row, the final response/offline-session update can be based on state produced by a failed attempt.

  • Even when the upstream refresh call is technically reusable, retrying it can duplicate side effects: extra token endpoint calls, rate-limit pressure, duplicate audit/security events, and slower refresh
    responses under contention.

I'd strongly advise not turning on this code by default. In Flux, when we change default functionality that can have a wide impact like this, we ship it under a feature flag. If we don't hear back about problems in that feature and we think it's a good behavior to have by default, we flip the flag's default value later after a few quarters. I think this is what a GA CNCF project with 10k stars like Dex should do for a feature with this impact surface.

@matheuscscp

matheuscscp commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

I'd also consider making the hard-coded parameters inputs with defaults (i.e. the number of retries, backoff, jitter). Maybe the hard-coded / defaults are good for some users, but others may need to fine-tune...

@RonnanSouza

Copy link
Copy Markdown
Author

Hey Pimenta, thanks for the review.

I'd also consider making the hard-coded parameters inputs with defaults (i.e. the number of retries, backoff, jitter). Maybe the hard-coded / defaults are good for some users, but others may need to fine-tune...

I set this way because I saw a similar approach in the k8s retry mechanism. But I can make it customizable with the enable/disable config from the previous comment.

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.

2 participants