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/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')"
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"
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 =>
{