Skip to content

Cache Azure user delegation key for SAS signing#781

Merged
alamb merged 5 commits into
apache:mainfrom
emilk:emilk/cache-azure-user-delegation-key
Jul 10, 2026
Merged

Cache Azure user delegation key for SAS signing#781
alamb merged 5 commits into
apache:mainfrom
emilk:emilk/cache-azure-user-delegation-key

Conversation

@emilk

@emilk emilk commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

AzureClient::signer() calls get_user_delegation_key() — a GetUserDelegationKey network round-trip (POST /?restype=service&comp=userdelegationkey) — on every signed_url / signed_urls call. A user delegation key is reusable until its expiry, so re-fetching it per request is wasteful and, under load, gets throttled by Azure (HTTP 503 ServerBusy).

Notably the other backends do not make an uncached network call while signing: the AWS signer signs locally from its cached credential, and GCP caches its signing credentials. Azure is the outlier — it makes a GetUserDelegationKey call on top of the already-cached AAD token.

We hit this in production: a workload issuing many presigned-URL requests against Azure Blob drove ~100k GetUserDelegationKey POSTs in 2h (~840/min), ~35% of which returned 503. The retry client backed off 10× (~10s) and then surfaced the error to the caller.

What changes are included in this PR?

Reuse the existing TokenCache<T> to cache the user delegation key on AzureClient:

  • A long-lived key (12h) is fetched once and reused to sign many short-lived SAS tokens.
  • TokenCache only returns a key with more than its min_ttl (2h here) remaining, so any SAS no longer than that is guaranteed to expire before its key (a SAS must not outlive the key it is signed with). The rare longer-lived SAS fetch a dedicated key.
  • The cache entry expires when the key Azure actually granted does (SignedExpiry), not when we requested.
  • Inherits TokenCache’s refresh-race double-check and fetch backoff.

TokenCache::with_min_ttl is extended to the azure-base feature so the Azure client can configure the cache (previously gated to aws-base/gcp-base).

The signed SAS tokens themselves are unchanged (still caller-specified expires_in).

Are there any user-facing changes?

No public API changes. Presigned-URL generation against Azure now performs far fewer GetUserDelegationKey requests.

Tests

Added azure::client::tests::test_delegation_key_expiry for the granted-vs-requested expiry parsing. The caching/refresh machinery itself is covered by the existing client::token tests.

Related issues / PRs

No existing issue tracks this specifically. Related:

@emilk
emilk force-pushed the emilk/cache-azure-user-delegation-key branch from 3aa759a to 2690d70 Compare June 25, 2026 02:24
@emilk
emilk marked this pull request as ready for review June 25, 2026 02:26
Comment thread src/azure/client.rs
@alamb

alamb commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Notably the other backends do not make an uncached network call while signing: the AWS signer signs locally from its cached credential, and GCP caches its signing credentials. Azure is the outlier — it makes a GetUserDelegationKey call on top of the already-cached AAD token.

Agreed -- they use TokenCache

S3:

pub cache: TokenCache<Arc<AwsCredential>>,

GCP:

cache: TokenCache<Arc<T::Credential>>,

@alamb alamb left a comment

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.

Thanks @emilk -- I went through this carefully and I think it makes sense to me

I left a few small comments but I don't think any of them are required for merge (we can do them as follow on PRs for example)

The one thing I would like to see before we merge this is some test of the actual flow (e.g. that cached credentials are actually fetched and reused). I took the liberty of pushing such a test -- please give it a look

Also, @kevinjqliu as our resident Azure expert, does this behavior make sense to you?

Comment thread src/azure/client.rs Outdated
/// How long a freshly fetched user delegation key is requested to remain valid.
///
/// The SAS tokens we sign with it stay short-lived; this only bounds how often
/// we call `GetUserDelegationKey`. Azure caps the key lifetime at 7 days.

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.

Can you add a URL that with some evidence for this claim? It seems this could be a good one

https://learn.microsoft.com/en-us/rest/api/storageservices/get-user-delegation-key#request-body

Start Required. The start time for the user delegation SAS, in ISO date format. It must be a valid date and time within seven days of the current date.

Comment thread src/azure/client.rs Outdated

/// How long a freshly fetched user delegation key is requested to remain valid.
///
/// The SAS tokens we sign with it stay short-lived; this only bounds how often

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.

I think it would help to define SAS at least once (I realize it is used elsewhere in this module without that)

Suggested change
/// The SAS tokens we sign with it stay short-lived; this only bounds how often
/// The shared access signature (SAS) tokens we sign with it stay short-lived; this only bounds how often

Comment thread src/azure/client.rs Outdated

/// Fetch a user delegation key valid for `validity` and wrap it as a
/// [`TemporaryToken`] so [`TokenCache`] can expire it.
async fn fetch_delegation_key(

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.

I found the naming somewhat confusing here (as it wasn't clear to me what the relationship between get_user_delegation_key and fetch_user_delegation_key was (get and fetch are very similar). I wa sall the more confused because get actually does the network call, when I would normally exepct fetch to do that

Something like this might be easier to understand

  • fetch_delegation_key --> get_delegation_key
  • (current) get_delegation_key --> get_delegation_key_inner

@alamb

alamb commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

I took the liberty of pushing the comment changes and function name changes. I'll plan to merge this once CI passes

Thanks @emilk

@alamb
alamb merged commit c66dd12 into apache:main Jul 10, 2026
9 checks passed
@kevinjqliu

kevinjqliu commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

I verified this with my own azure storage account, it works great. Thanks for adding this!

I think there are a few follow-ups could make the delegation-key cache more flexible and robust:

  • Make DELEGATION_KEY_VALIDITY optionally configurable so users can limit how long reusable keys remain valid. This setting should never shorten a key below the requested SAS lifetime.
  • Remove the fixed DELEGATION_KEY_MIN_TTL. Azure returns the key’s actual SignedExpiry, so cache it and reuse the key only when SignedExpiry is later than the requested SAS expiry. Otherwise, fetch a new key.
  • Treat Azure’s returned SignedStart and SignedExpiry as authoritative. Return a clear error if they are malformed or do not cover the full SAS lifetime.

EDIT: tracking as #807

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.

Cache Azure user delegation key for SAS signing

3 participants