Retry transient token/connection failures in az iot ops upgrade - #898
Retry transient token/connection failures in az iot ops upgrade#898huguesBouvier wants to merge 1 commit into
Conversation
`az iot ops upgrade` can abort with exit 1 before doing any work when ARM token acquisition (AzureCliCredential -> `az account get-access-token`) fails with a transient `Connection reset by peer`, or when an ARM call hits a connection reset / DNS / read-timeout flake. These are environmental issues, not product or user errors. This adds a small, bounded `retry_on_transient_error` helper in `util/az_client.py` (default 3 attempts, linear 15s * attempt backoff) that retries ONLY connection/token-acquisition transients (ServiceRequestError, ServiceResponseError, builtin ConnectionError/TimeoutError, and connection/ DNS message markers). HTTP 429/5xx are intentionally excluded because the azure-core transport already retries those via its RetryPolicy. The upgrade flow (`upgrade2.py`) wraps its idempotent operations with the helper: manager construction + `analyze_cluster` (ARM reads) and each extension create/update/delete, instance update, registry-endpoint creation, and secretsync migration (idempotent ARM PUT/DELETE). Genuine errors still fail fast. Adds unit tests for the classifier and the retry helper.
| # ClientAuthenticationError / CredentialUnavailableError caused by a transient | ||
| # `az account get-access-token` connection reset, without retrying real auth errors. | ||
| message = str(getattr(error, "message", "") or error).lower() | ||
| return any(marker in message for marker in _TRANSIENT_ERROR_MARKERS) |
There was a problem hiding this comment.
The 429/5xx exclusion is documented but not enforced. is_transient_error has no HttpResponseError check, so a status error falls through to the substring markers. A 504 message lowercases to contain "gateway timeout", which matches the bare "timeout" marker and comes back transient. The azure core transport already retries 5xx, which test_ops_upgrade_retry_assertion proves for the 503 case, so a persistent 504 gets retried again here. That is the double retry this was meant to avoid, plus about 45s of added sleep. The same breadth retries any genuine failure whose message mentions a timeout or connection error, which is at odds with the "genuine errors still fail fast" note in HISTORY. We should return False for a status bearing HttpResponseError before the marker fallback, and keep the fallback to the credential and connection errors that have no status. The test at test_az_client_unit.py:63 uses message="service error", so it never exercises the real 504 wording. A case asserting False on a "gateway timeout" message would pin it.
| lambda ext=ext, op_type=op_type: self._apply_single_operation( | ||
| ext=ext, op_type=op_type, headers=headers | ||
| ), | ||
| context=f"{op_type.value} extension", |
There was a problem hiding this comment.
The retry wraps each begin_* plus wait_for_terminal_state as a whole, so an exhausted transport error abandons the poller and resubmits the whole operation. On top of the transport's own retries, one failing request can reach around a dozen HTTP attempts, and the fixed 15s then 30s sleeps add up. Worst case across the extension ops plus instance, registry and secretsync is several minutes of pure added sleep on an interactive command. The case this PR describes, a token failure before any work during the UpgradeManager build and analyze_cluster, is the clear win and worth keeping. For the ARM writes the transport already retries connection and 5xx, so the extra layer is less clearly earning the latency. We should scope the helper to the reads before any work, or at least use a smaller backoff and fewer attempts for the writes.
| default_spc = self.secretsync_migration.migrate_to_v2(headers) | ||
| default_spc = retry_on_transient_error( | ||
| lambda: self.secretsync_migration.migrate_to_v2(headers), | ||
| context="secretsync migration", |
There was a problem hiding this comment.
migrate_to_v2 runs a PATCH, then zero or more SecretSync PATCHes, then a delete of the legacy SPC, working from state cached at construction. Wrapping the whole thing means a transient in the delete restarts the sequence from the top rather than resuming. The re-run replays the PATCHes fine since they recompute the same content, but the delete is the concern. If the first delete succeeded server side and only the response was lost, the retry re-issues it and the vendored client maps a 404 to an error, so a converged migration could still surface as a hard failure. I could not confirm the exact RP behavior for a duplicate delete statically. We should tolerate an already deleted SPC on retry, or resume rather than restart, so a lost delete response does not fail a completed migration.
| ========== | ||
|
|
||
| * Initial baseline. | ||
| * `az iot ops upgrade` now retries transient token-acquisition and network |
There was a problem hiding this comment.
This project does not use HISTORY.rst. Release notes are captured on the GitHub releases, so we should drop this change. The PR checklist still asking for a HISTORY.rst entry is misleading, but that is a separate cleanup.
| return any(marker in message for marker in _TRANSIENT_ERROR_MARKERS) | ||
|
|
||
|
|
||
| def retry_on_transient_error( |
There was a problem hiding this comment.
Following on the latency point in the other comment, and thinking about the approach more broadly, it may be worth splitting this rather than tuning the helper. The transient failures are really two kinds, and azure core already handles one.
For the ARM HTTP transients, connection reset, read timeout and 5xx on retryable methods, RetryPolicy already retries, which is where that double retry comes from. oci_client.get_oci_retry_policy already sets one up in this package, so the get*_client factories could carry a RetryPolicy and the write path wrapping could go away.
The token acquisition case this PR is really about is the part RetryPolicy cannot cover. AzureCliCredential.get_token failing while shelling out to az account get-access-token surfaces as ClientAuthenticationError, and RetryPolicy re-raises that without retrying (_retry.py send). Since get_token runs inside the pipeline, no transport retry helps.
So the more elegant shape is to lean on RetryPolicy for the HTTP transients and drop the per call site wrapping, then handle the one real gap centrally by wrapping the shared AZURE_CLI_CREDENTIAL with a small retrying get_token. That fixes the token flake once for every command instead of only upgrade, and removes the lambda boilerplate.
Azure SRE Agent - automated reviewReviewing the full diff (head Well-scoped change: the helper is documented, deliberately excludes HTTP status errors so it does not double-retry on top of azure-core's Blocking
Suggestions
Nits
Summary — the retry helper and its call sites are structurally sound and well tested, but This is an automated review and may be incomplete; please verify findings before acting on them. |
Description
az iot ops upgradecan abort with exit 1 before doing any work when ARM token acquisition (AzureCliCredential→az account get-access-token) fails with a transientConnection reset by peer, or when a subsequent ARM call hits a connection-reset / DNS / read-timeout flake. These are environmental issues, not product or user errors, and today they surface as a hard command failure.This mirrors, on the product side, a test-side retry we added for the same transient failure (internal bug 38787405 / recurring infra pattern).
Change
retry_on_transient_errorinazext_edge/edge/util/az_client.py(default 3 attempts, linear 15s × attempt backoff).ServiceRequestError,ServiceResponseError, builtinConnectionError/TimeoutError, and connection/DNS message markers (e.g.connection reset,reset by peer,temporary failure in name resolution).azure-coretransport already retries those via itsRetryPolicy(verified by the existingtest_ops_upgrade_retry_assertion), so retrying here would double-retry.az loginneeded, validation errors) fail fast.upgrade2.pywraps its idempotent operations with the helper:UpgradeManagerconstruction +analyze_cluster(ARM reads — this is the "before doing any work" token-acquisition point).Testing
is_transient_error(transient markers, non-transient auth/validation, and that 429/5xx are not retried here) andretry_on_transient_error(succeeds after retries, fails fast on non-transient, exhausts attempts).pytest azext_edge/tests/utility/test_az_client_unit.py azext_edge/tests/edge/orchestration/test_upgrade2_unit.py→ 212 passed.flake8clean;pylintintroduces no new findings (pre-existing R0917s only).History
HISTORY.rst.