Skip to content

Make dotnet-eng-status deployment notifications best-effort (fix intermittent 500)#6643

Open
missymessa wants to merge 6 commits into
mainfrom
missymessa/fix-deployment-notify-500
Open

Make dotnet-eng-status deployment notifications best-effort (fix intermittent 500)#6643
missymessa wants to merge 6 commits into
mainfrom
missymessa/fix-deployment-notify-500

Conversation

@missymessa

Copy link
Copy Markdown
Member

Problem

The Helix deploy pipelines (dotnet-helix-service-deploy, dotnet-helix-machines deploy) intermittently fail their "Notify dotnet-eng-status/start" step with an HTTP 500, e.g. build 3024453:

POST https://dotneteng-status-staging.azurewebsites.net/api/deployment/dotnet-helix-service/2026071601/start -> (500) Internal Server Error

The 500 comes from DeploymentController.MarkStart in the DotNetStatus (dotnet-eng-status) app, not from Helix. Recording a deployment annotation is best-effort telemetry, but the controller had two problems that let any hiccup become a 500 that shows up as a red step in the deploy pipeline:

  1. The Grafana annotation call was wrapped in ExponentialRetry.RetryAsync(..., isRetryable: e => true), which retries every exception (including non-transient 4xx from EnsureSuccessStatusCode()) up to 10 times and then rethrows.
  2. Neither the retried Grafana call nor the follow-up table upsert was wrapped in a try/catch, so any rethrown/unretried exception became an unhandled 500.

Fix

  • Narrow the retry predicate to a new IsTransientFailure helper: retry only network errors, timeouts, and 5xx responses (HttpRequestException with StatusCode null or >= 500, or TaskCanceled/OperationCanceled). Non-transient failures now fail fast instead of being retried 10× and rethrown.
  • Wrap the MarkStart and MarkEnd bodies in try/catch that logs the error and returns NoContent(). Best-effort deployment notifications never fail the calling pipeline again.
  • MarkEnd now logs and returns NoContent() instead of NotFound() when no matching start record exists (a missing/failed start notification should not turn the end notification into a 404).

Notes

  • Behavior on the success path is unchanged.
  • Observability on the staging app is currently broken (App Insights instrumentation key points at an empty component), so the exact failing dependency (Grafana POST vs. table upsert) could not be captured from telemetry; this fix hardens both paths regardless.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

The DeploymentController start/end endpoints are best-effort telemetry
(Grafana annotation + table upsert). Any failure was retried on every
exception and then rethrown, surfacing as a 500 to the calling deploy
pipeline. Narrow retries to transient failures and swallow errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b47fce22-0f00-4880-8509-85c2b30187ee
@chcosta

chcosta commented Jul 17, 2026

Copy link
Copy Markdown
Member

Recording a deployment annotation

what's the deployment annotation used for? where does it surface?

Narrow the retry predicate to a new IsTransientFailure helper: retry only network errors, timeouts, and 5xx responses (HttpRequestException with StatusCode null or >= 500, or TaskCanceled/OperationCanceled). Non-transient failures now fail fast instead of being retried 10× and rethrown.

this seems good

Wrap the MarkStart and MarkEnd bodies in try/catch that logs the error and returns NoContent(). Best-effort deployment notifications never fail the calling pipeline again.

isn't this just masking the failure? if we're just going to do this, then it sounds like the notifications aren't needed. can we just remove them? was this for rollout scorecard or something else?

Comment thread src/DotNet.Status.Web/DotNet.Status.Web/Controllers/DeploymentController.cs Outdated
Comment thread src/DotNet.Status.Web/DotNet.Status.Web/Controllers/DeploymentController.cs Outdated
Comment thread src/DotNet.Status.Web/DotNet.Status.Web/Controllers/DeploymentController.cs Outdated
@epananth

Copy link
Copy Markdown
Member

There was a transient error where one will have to go delete a file in Azure portal for this service to work. I think Haruna and Meghna did that a few times.

It would be nice if this could cover that case too

…zure Table record

Per review feedback (garath/chcosta): remove the fragile direct writes to the
Grafana annotations API from MarkStart/MarkEnd and rely solely on the Azure Table
store, which Grafana reads from directly. This eliminates the Grafana HTTP
dependency (and its ApiToken/retry path) that was the source of the intermittent
500s, so the best-effort endpoints simply record start/end to table storage.
Also simplifies the failure log messages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26b50f05-b732-427b-a818-8e6d58638b47
@missymessa

Copy link
Copy Markdown
Member Author

Good questions — and the review consensus (thanks @garath) actually resolves the masking concern by changing the approach:

What the annotation is / where it surfaces: the deployment annotation is a Grafana dashboard annotation — a vertical marker on the time-series dashboards showing when each service was deployed (start→end span, tagged deploy, deploy-<service>, <service>). It's used to correlate metric/behavior changes with deployments while looking at Grafana (not a rollout scorecard).

On "isn't this just masking the failure?": you're right that swallowing a failing Grafana push would just hide a broken notification. So instead of masking it, this revision removes the fragile direct call to the Grafana annotations API entirely. MarkStart/MarkEnd now only write the deployment record to the Azure Table, and Grafana reads the annotations from that table directly. That deletes the HTTP dependency (and its ApiToken + 10× retry-then-rethrow path) that was actually producing the intermittent 500s.

So we keep the useful signal (the durable table record that Grafana renders), and the endpoint no longer has a flaky external call to mask. The best-effort try/catch now only guards the Azure Table write (managed-identity, SDK-retried), so a genuine storage outage still won't fail the deploy pipeline, but there's no longer a Grafana-push failure being hidden. Pushed in b4f7c83.

@missymessa

Copy link
Copy Markdown
Member Author

Thanks @epananth — that "have to go delete a file in the Azure portal to get the service working again" case is the leftover app_offline.htm, and it's already been fixed separately in #6580 (merged 2026-05-22, WI 8535).

Root cause there: the AzureRmWebAppDeployment@4 task defaults TakeAppOfflineFlag to true, which drops an app_offline.htm during deploy; with zipDeploy that's unnecessary (the deploy is atomic) and intermittently the file wasn't cleaned up, leaving the app offline until someone deleted it manually (what Haruna/Meghna were doing). #6580 sets TakeAppOfflineFlag: false in eng/deploy.yaml so the file is never created.

So that failure mode is covered by #6580; this PR (#6643) is the separate fix for the intermittent 500s from the deployment-notification endpoint. Let me know if you've seen the app_offline.htm situation recur after #6580 shipped — if so I'll reopen that thread.

@missymessa

Copy link
Copy Markdown
Member Author

There was a transient error where one will have to go delete a file in Azure portal for this service to work. I think Haruna and Meghna did that a few times.

It would be nice if this could cover that case too

@epananth do you still see that happening? I put in a fix for that a while ago: #6580

@missymessa
missymessa requested a review from garath July 20, 2026 22:37
Comment thread src/DotNet.Status.Web/DotNet.Status.Web/Controllers/DeploymentController.cs Outdated
@missymessa
missymessa requested a review from garath July 21, 2026 20:42
…ntTableOptions

Per review feedback (garath): the DeploymentController no longer talks to Grafana,
so binding it to GrafanaOptions was inconsistent. Introduce a dedicated
DeploymentTableOptions (TableUri/TableName) bound to a new "Deployments" config
section (matching the existing AzureTableTokenStoreOptions pattern) and use it in
both DeploymentController and AnnotationsController, which read/write the same
deployments annotation table.

GrafanaOptions is slimmed to just WebhookSecret (still used by AlertHookController);
the now-dead BaseUrl/ApiToken fields (only used by the removed Grafana annotations
HTTP calls) and their settings.json entries are removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9df4c7ab-7f6e-4f13-a16c-3b01a0449ca2

@garath garath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh, sorry for swapping the review status. I just noticed that removing grafana-api-token in the config also removes its need to be in the secret-manager manifest. That's a manually-managed secret so removing it is a big win for overhead.

@garath

garath commented Jul 22, 2026

Copy link
Copy Markdown
Member

Oh, sorry for swapping the review status. I just noticed that removing grafana-api-token in the config also removes its need to be in the secret-manager manifest. That's a manually-managed secret so removing it is a big win for overhead.

These secrets are no longer needed:

garath added a commit to garath/dnceng that referenced this pull request Jul 22, 2026
The only runtime consumer of the grafana-api-token secret (minted by this
type) is DotNet.Status.Web, which is being migrated off Grafana in
dotnet#6643. Remove the secret type and its Readme entry.

Note: the 3 dotneteng-status .vault-config manifests still declare a
grafana-api-token secret of this type; those entries are being removed
separately.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f4dd4f39-9896-49e1-a690-c9930af65ebd
missymessa and others added 2 commits July 22, 2026 11:16
The DotNet.Status.Web deployment-notification endpoints no longer call the
Grafana annotations HTTP API (they write to the Azure Table that Grafana
reads from), so the manually-managed grafana-api-token secret is no longer
needed in the dotneteng-status local/prod/staging manifests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 66a3f42d-8d83-467c-bed6-651841b22ee8
@missymessa

Copy link
Copy Markdown
Member Author

Done in 189ac85 — removed the grafana-api-token secret from all three vault-config manifests:

  • .vault-config/dotneteng-status-local.yaml
  • .vault-config/dotneteng-status-prod.yaml
  • .vault-config/dotneteng-status-staging.yaml

Confirmed it's safe: that secret was only consumed by the DotNet.Status.Web Grafana-annotations HTTP call (now removed). The dashboard-deploy pipeline (eng/deploy-managed-grafana.yml) uses a separate grafana-admin-api-key in the AMG key vault, and grafana-webhook-secret (AlertHookController) is untouched. Good call on dropping the manually-managed secret.

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.

4 participants