From c9464e92d4de7dcc7d1695388a37dce35364ba7d Mon Sep 17 00:00:00 2001 From: "Bart Segers (M16)" Date: Tue, 28 Apr 2026 10:26:17 +0200 Subject: [PATCH 1/3] chore: add scripts to open test/prod envs in browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New helper scripts for quickly launching API health and UI in default browser: - open-test-in-browser.ps1 — opens TEST env - open-prod-in-browser.ps1 — opens PROD env - _open-env.ps1 — shared helper with error handling Co-Authored-By: Claude Sonnet 4.6 --- scripts/_open-env.ps1 | 28 ++++++++++++++++++++++++++++ scripts/open-prod-in-browser.ps1 | 2 ++ scripts/open-test-in-browser.ps1 | 2 ++ 3 files changed, 32 insertions(+) create mode 100644 scripts/_open-env.ps1 create mode 100644 scripts/open-prod-in-browser.ps1 create mode 100644 scripts/open-test-in-browser.ps1 diff --git a/scripts/_open-env.ps1 b/scripts/_open-env.ps1 new file mode 100644 index 0000000..7c92364 --- /dev/null +++ b/scripts/_open-env.ps1 @@ -0,0 +1,28 @@ +function Open-Env([string]$EnvFile) { + if (-not (Test-Path $EnvFile)) { + Write-Error "Env file not found: $EnvFile`nRun deploy.ps1 first to generate it." + exit 1 + } + + $config = @{} + Get-Content $EnvFile | Where-Object { $_ -match "^\s*[^#]\w+=.+" } | ForEach-Object { + $k, $v = $_ -split "=", 2 + $config[$k.Trim()] = $v.Trim() + } + + $required = "APP_SERVICE_HOSTNAME", "SWA_HOSTNAME" + $missing = $required | Where-Object { -not $config[$_] } + if ($missing) { + Write-Error "Missing or empty keys in ${EnvFile}: $($missing -join ", ")" + exit 1 + } + + $api = "https://$($config["APP_SERVICE_HOSTNAME"])/api/v1/health" + $ui = "https://$($config["SWA_HOSTNAME"])" + + Write-Host "Opening API : $api" + Write-Host "Opening UI : $ui" + + Start-Process $api + Start-Process $ui +} diff --git a/scripts/open-prod-in-browser.ps1 b/scripts/open-prod-in-browser.ps1 new file mode 100644 index 0000000..f646aeb --- /dev/null +++ b/scripts/open-prod-in-browser.ps1 @@ -0,0 +1,2 @@ +. "$PSScriptRoot\_open-env.ps1" +Open-Env "$PSScriptRoot\.env.prod" diff --git a/scripts/open-test-in-browser.ps1 b/scripts/open-test-in-browser.ps1 new file mode 100644 index 0000000..eb4ad34 --- /dev/null +++ b/scripts/open-test-in-browser.ps1 @@ -0,0 +1,2 @@ +. "$PSScriptRoot\_open-env.ps1" +Open-Env "$PSScriptRoot\.env.test" From 5e9457efd920620097baa3b12e779ec196c1a045 Mon Sep 17 00:00:00 2001 From: "Bart Segers (M16)" Date: Tue, 28 Apr 2026 10:56:38 +0200 Subject: [PATCH 2/3] feat: add exponential retry with messaging for health endpoint cold starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health page now retries up to 10 times with exponential backoff (2s → 4s → ... → 20s) when API is unreachable. Shows "Unable to reach the API — retrying (N of 10)" with cold-start explanation. Works in all environments (local dev, test, prod). Changes: - Increase health client timeout to 35s (accommodate cold-starting servers) - Configure resilience handler: 30s attempt timeout, 60s circuit breaker sampling - Component-level retry loop with exponential delay capping at 20s - Neutral messaging works for both deployed and local-dev scenarios Co-Authored-By: Claude Sonnet 4.6 --- .../AHKFlowApp.UI.Blazor/Pages/Health.razor | 58 +++++++++++++------ src/Frontend/AHKFlowApp.UI.Blazor/Program.cs | 18 ++++-- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/Frontend/AHKFlowApp.UI.Blazor/Pages/Health.razor b/src/Frontend/AHKFlowApp.UI.Blazor/Pages/Health.razor index 4cbc3d0..cb309a7 100644 --- a/src/Frontend/AHKFlowApp.UI.Blazor/Pages/Health.razor +++ b/src/Frontend/AHKFlowApp.UI.Blazor/Pages/Health.razor @@ -14,7 +14,17 @@ @if (_loading) { - Checking system health... + @if (_retryAttempt > 0) + { + + Unable to reach the API — retrying (@_retryAttempt of @MaxRetries) + The API is not responding. If it is deployed on the Free tier, the API and database may take a few minutes to cold start. + + } + else + { + Checking system health... + } } else if (_healthCheck != null) { @@ -105,9 +115,12 @@ @code { [Inject] private IAhkFlowAppApiHttpClient ApiClient { get; set; } = default!; + private const int MaxRetries = 10; + private HealthResponse? _healthCheck; private bool _loading = true; private bool _hasError; + private int _retryAttempt; private readonly CancellationTokenSource _cts = new(); protected override async Task OnInitializedAsync() @@ -119,26 +132,37 @@ { _loading = true; _hasError = false; + _retryAttempt = 0; StateHasChanged(); // render the spinner before awaiting - try - { - _healthCheck = await ApiClient.GetHealthAsync(_cts.Token); - if (_healthCheck is null) - _hasError = true; - } - catch (OperationCanceledException) + while (true) { - // page was disposed - } - catch - { - _hasError = true; - } - finally - { - _loading = false; + try + { + _healthCheck = await ApiClient.GetHealthAsync(_cts.Token); + if (_healthCheck is null) + _hasError = true; + break; + } + catch (OperationCanceledException) + { + break; // page was disposed + } + catch + { + if (++_retryAttempt >= MaxRetries) + { + _hasError = true; + break; + } + StateHasChanged(); + int delaySeconds = Math.Min((int)Math.Pow(2, _retryAttempt), 20); + try { await Task.Delay(TimeSpan.FromSeconds(delaySeconds), _cts.Token); } + catch (OperationCanceledException) { break; } + } } + + _loading = false; } private async Task RefreshHealthAsync() => await LoadHealthAsync(); diff --git a/src/Frontend/AHKFlowApp.UI.Blazor/Program.cs b/src/Frontend/AHKFlowApp.UI.Blazor/Program.cs index 623a80a..8caa767 100644 --- a/src/Frontend/AHKFlowApp.UI.Blazor/Program.cs +++ b/src/Frontend/AHKFlowApp.UI.Blazor/Program.cs @@ -25,9 +25,14 @@ builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(new Uri(builder.HostEnvironment.BaseAddress), apiBaseUrl); - client.Timeout = TimeSpan.FromSeconds(30); + client.Timeout = TimeSpan.FromSeconds(35); }) - .AddStandardResilienceHandler(); + .AddStandardResilienceHandler(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(32); + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(60); + }); builder.Services.AddHttpClient(client => { @@ -67,10 +72,15 @@ builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(apiBaseUrl); - client.Timeout = TimeSpan.FromSeconds(30); + client.Timeout = TimeSpan.FromSeconds(35); }) .AddHttpMessageHandler() - .AddStandardResilienceHandler(); + .AddStandardResilienceHandler(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(32); + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(60); + }); builder.Services.AddHttpClient(client => { From 904a2a8407d12510a6128f2bb473cd8dc9f131ac Mon Sep 17 00:00:00 2001 From: "Bart Segers (M16)" Date: Tue, 28 Apr 2026 11:04:21 +0200 Subject: [PATCH 3/3] feat: show tier-specific deployment time estimates Display estimated deployment duration based on selected tier: - Basic: 3-5 minutes - Free: 5-15 minutes (with note that Free is slower due to resource constraints) Co-Authored-By: Claude Sonnet 4.6 --- scripts/deploy.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index e9eb7b3..15db716 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -343,7 +343,12 @@ $EntraClientId = [string]$EntraInfo.ClientId Write-Success "Entra app: $EntraClientId (tenant $EntraTenantId)" # Deploy Bicep -Write-Host " Deploying Bicep template (usually 3-10 minutes, longer if Azure is slow)..." +$deployMsg = if ($Tier -eq 'free') { + "Deploying Bicep template (5-15 minutes on Free tier; Free tier is slower than Basic due to resource constraints)..." +} else { + "Deploying Bicep template (3-5 minutes on Basic tier)..." +} +Write-Host " $deployMsg" Write-Host " Progress updates every 15s; first update may take ~30s while deployment registers." $BicepTemplate = Join-Path $RepoRoot "infra\main.bicep" $DeploymentName = "deploy-${Environment}-$(Get-Date -Format 'yyyyMMdd-HHmmss')"