diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 2585da4..5b43064 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -6,6 +6,27 @@ All notable changes to the Confluence Page Exporter tool are documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Added + +- Confluence deployment-type auto-detection from `--base-url`: `*.atlassian.net` + hosts are treated as Cloud, everything else as on-prem; an explicit + `--auth-type` takes precedence. An invalid `--auth-type` value now fails with + a clear error instead of being silently ignored, and `config show` displays + the effective mode (e.g. `cloud (auto-detected)`). The `cloud` mode is + preparatory for now: the existing (v1) API client is used and a warning is + printed — full Cloud API support arrives in upcoming releases. + +### Changed + +- HTTP request retries honour the server's `Retry-After` header (429/503): the + wait is taken from the header, capped at 60 seconds — matters for Confluence + Cloud rate limits. +- POST requests are now retried on 429: the rate limiter rejects the request + before processing, so duplicated side effects are impossible. POST is still + never retried on network errors or 5xx. + ## [2.16.0] — 2026-06-24 ### Added diff --git a/CHANGELOG.md b/CHANGELOG.md index 907e531..7081bf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/). +## [Unreleased] + +### Добавлено + +- Автоопределение типа развёртывания Confluence по `--base-url`: хосты + `*.atlassian.net` считаются Cloud, остальные — on-prem; явный `--auth-type` + приоритетнее. Недопустимое значение `--auth-type` теперь приводит к понятной + ошибке вместо тихого игнорирования, а `config show` показывает эффективный + режим (например `cloud (auto-detected)`). Режим `cloud` пока подготовительный: + используется прежний API-клиент (v1) и выводится предупреждение — полноценная + поддержка Cloud API появится в следующих релизах. + +### Изменено + +- Повторы HTTP-запросов учитывают заголовок `Retry-After` из ответа сервера + (429/503): ожидание берётся из заголовка с потолком 60 секунд — важно для + рейт-лимитов Confluence Cloud. +- POST-запросы теперь повторяются при 429: рейт-лимитер отклоняет запрос до + обработки, поэтому дублирование побочных эффектов исключено. При сетевых + ошибках и 5xx POST по-прежнему не повторяется. + ## [2.16.0] — 2026-06-24 ### Добавлено diff --git a/README.en.md b/README.en.md index 9e69c0f..5953743 100644 --- a/README.en.md +++ b/README.en.md @@ -24,7 +24,7 @@ The tool supports: - an `index.html` file with the page content in storage representation - attachments as separate files - a marker file `.id_` (its body is JSON with the original title and space key) for stable page identification and version tracking -- Authentication modes: `--auth-type onprem` and `--auth-type cloud` +- Authentication modes: `--auth-type onprem` and `--auth-type cloud`; by default the type is auto-detected from `--base-url` (`*.atlassian.net` hosts → `cloud`) - Multi-layered configuration with priority: CLI > environment variables > file > default value - Global `--verbose` flag for detailed (debug-level) output - Dry-run support where applicable @@ -294,7 +294,7 @@ Force-downloads a Confluence page (or subtree) to the local disk. Differing page - `--page-id` or `--page-title` (exactly one must be specified) - `--output-dir` (required) - `--recursive` (optional) -- `--auth-type onprem|cloud` (optional, default `onprem`) +- `--auth-type onprem|cloud` (optional, default: auto-detected from `--base-url`) - `--dry-run` (optional) - `--report` (optional) @@ -324,7 +324,7 @@ Downloads pages from the server, overwriting only those that are newer on the se - `--page-id` or `--page-title` (exactly one must be specified) - `--output-dir` (required) - `--recursive` (optional) -- `--auth-type onprem|cloud` (optional, default `onprem`) +- `--auth-type onprem|cloud` (optional, default: auto-detected from `--base-url`) - `--dry-run` (optional) - `--report` (optional) @@ -355,7 +355,7 @@ Force-uploads local pages to the server. Differing pages are overwritten with th - `--page-id` or `--page-title` (optional, explicit root page) - `--recursive` (optional) - `--multi-tree` (optional; `--source-dir` points to a directory with several trees — each is processed with its own space; incompatible with `--page-id`/`--page-title`) -- `--auth-type onprem|cloud` (optional, default `onprem`) +- `--auth-type onprem|cloud` (optional, default: auto-detected from `--base-url`) - `--dry-run` (optional) - `--report` (optional) @@ -408,7 +408,7 @@ If the same page's content was also changed on the server, the structural move i - `--page-id` or `--page-title` (optional, explicit root page) - `--recursive` (optional) - `--multi-tree` (optional; `--source-dir` points to a directory with several trees — each is processed with its own space; incompatible with `--page-id`/`--page-title`) -- `--auth-type onprem|cloud` (optional, default `onprem`) +- `--auth-type onprem|cloud` (optional, default: auto-detected from `--base-url`) - `--dry-run` (optional) - `--report` (optional) @@ -437,7 +437,7 @@ Creates new Confluence pages from local content. - `--source-dir` (required) - `--parent-id` or `--parent-title` (optional) - `--recursive` (optional) -- `--auth-type onprem|cloud` (optional, default `onprem`) +- `--auth-type onprem|cloud` (optional, default: auto-detected from `--base-url`) - `--dry-run` (optional) ### upload create example @@ -470,7 +470,7 @@ With `--detect-source`, the Confluence version history is additionally analysed - `--recursive` (optional) - `--match-by-title` (optional) - `--detect-source` (optional) — analyse version history to determine the source of renames and moves (extra API calls) -- `--auth-type onprem|cloud` (optional, default `onprem`) +- `--auth-type onprem|cloud` (optional, default: auto-detected from `--base-url`) ### Matching strategy diff --git a/README.md b/README.md index fcb4085..5e6e558 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - файл `index.html` с контентом страницы в storage representation - вложения как отдельные файлы - файл-маркер `.id_` (в теле — JSON с оригинальным заголовком и ключом пространства) для стабильной идентификации страницы и отслеживания версии -- Режимы аутентификации: `--auth-type onprem` и `--auth-type cloud` +- Режимы аутентификации: `--auth-type onprem` и `--auth-type cloud`; по умолчанию тип определяется автоматически по `--base-url` (хосты `*.atlassian.net` → `cloud`) - Многоуровневая конфигурация с приоритетом: CLI > переменные окружения > файл > значение по умолчанию - Глобальный параметр `--verbose` для подробного (debug-level) вывода - Поддержка dry-run там, где применимо @@ -294,7 +294,7 @@ ConfluencePageExporter upload merge --source-dir ./export/MyPage --recursive --r - `--page-id` или `--page-title` (обязательно указать ровно один) - `--output-dir` (обязательный) - `--recursive` (опционально) -- `--auth-type onprem|cloud` (опционально, по умолчанию `onprem`) +- `--auth-type onprem|cloud` (опционально, по умолчанию — автоопределение по `--base-url`) - `--dry-run` (опционально) - `--report` (опционально) @@ -324,7 +324,7 @@ ConfluencePageExporter download update \ - `--page-id` или `--page-title` (обязательно указать ровно один) - `--output-dir` (обязательный) - `--recursive` (опционально) -- `--auth-type onprem|cloud` (опционально, по умолчанию `onprem`) +- `--auth-type onprem|cloud` (опционально, по умолчанию — автоопределение по `--base-url`) - `--dry-run` (опционально) - `--report` (опционально) @@ -355,7 +355,7 @@ ConfluencePageExporter --report download merge \ - `--page-id` или `--page-title` (опционально, явное указание корневой страницы) - `--recursive` (опционально) - `--multi-tree` (опционально; `--source-dir` указывает на каталог с несколькими деревьями — каждое обрабатывается со своим пространством; несовместимо с `--page-id`/`--page-title`) -- `--auth-type onprem|cloud` (опционально, по умолчанию `onprem`) +- `--auth-type onprem|cloud` (опционально, по умолчанию — автоопределение по `--base-url`) - `--dry-run` (опционально) - `--report` (опционально) @@ -408,7 +408,7 @@ ConfluencePageExporter upload update \ - `--page-id` или `--page-title` (опционально, явное указание корневой страницы) - `--recursive` (опционально) - `--multi-tree` (опционально; `--source-dir` указывает на каталог с несколькими деревьями — каждое обрабатывается со своим пространством; несовместимо с `--page-id`/`--page-title`) -- `--auth-type onprem|cloud` (опционально, по умолчанию `onprem`) +- `--auth-type onprem|cloud` (опционально, по умолчанию — автоопределение по `--base-url`) - `--dry-run` (опционально) - `--report` (опционально) @@ -437,7 +437,7 @@ ConfluencePageExporter --report upload merge \ - `--source-dir` (обязательный) - `--parent-id` или `--parent-title` (опционально) - `--recursive` (опционально) -- `--auth-type onprem|cloud` (опционально, по умолчанию `onprem`) +- `--auth-type onprem|cloud` (опционально, по умолчанию — автоопределение по `--base-url`) - `--dry-run` (опционально) ### Пример upload create @@ -470,7 +470,7 @@ ConfluencePageExporter upload create \ - `--recursive` (опционально) - `--match-by-title` (опционально) - `--detect-source` (опционально) — анализировать историю версий для определения источника переименований и перемещений (дополнительные API-вызовы) -- `--auth-type onprem|cloud` (опционально, по умолчанию `onprem`) +- `--auth-type onprem|cloud` (опционально, по умолчанию — автоопределение по `--base-url`) ### Стратегия сопоставления diff --git a/src/ConfluencePageExporter/Commands/ConfigShowCommandHandler.cs b/src/ConfluencePageExporter/Commands/ConfigShowCommandHandler.cs index e7135af..ae5b1d0 100644 --- a/src/ConfluencePageExporter/Commands/ConfigShowCommandHandler.cs +++ b/src/ConfluencePageExporter/Commands/ConfigShowCommandHandler.cs @@ -69,7 +69,7 @@ private void PrintGlobal() PrintValue(" Username", g.Username, "Global:Username"); PrintValue(" Token", Mask(g.Token), "Global:Token"); PrintValue(" SpaceKey", g.SpaceKey, "Global:SpaceKey"); - PrintValue(" AuthType", g.AuthType ?? "onprem", "Global:AuthType"); + PrintValue(" AuthType", DescribeAuthType(g), "Global:AuthType"); PrintValue(" Verbose", (g.Verbose ?? false).ToString(), "Global:Verbose"); PrintValue(" DryRun", (g.DryRun ?? false).ToString(), "Global:DryRun"); PrintValue(" Recursive", (g.Recursive ?? false).ToString(), "Global:Recursive"); @@ -184,6 +184,24 @@ private static bool HasEnvVar(string configKey) return Environment.GetEnvironmentVariable(envKey) != null; } + /// + /// An explicit AuthType is echoed as-is (with a hint when unrecognised — + /// the API client registration would reject it with the same expectation); + /// an absent one shows what deployment auto-detection resolves to, so the + /// operator sees the effective mode, not just "(not set)". + /// + private static string DescribeAuthType(GlobalOptions g) + { + if (!string.IsNullOrWhiteSpace(g.AuthType)) + return DeploymentTypeResolver.TryParse(g.AuthType, out _) + ? g.AuthType.Trim().ToLowerInvariant() + : $"{g.AuthType} (invalid: expected onprem|cloud)"; + + return DeploymentTypeResolver.Autodetect(g.BaseUrl) == DeploymentType.Cloud + ? "cloud (auto-detected)" + : "onprem"; + } + private static string? Mask(string? value) { if (string.IsNullOrEmpty(value)) diff --git a/src/ConfluencePageExporter/Infrastructure/CommandDefinitions.cs b/src/ConfluencePageExporter/Infrastructure/CommandDefinitions.cs index 39e518e..cab8b9d 100644 --- a/src/ConfluencePageExporter/Infrastructure/CommandDefinitions.cs +++ b/src/ConfluencePageExporter/Infrastructure/CommandDefinitions.cs @@ -19,7 +19,7 @@ public static RootCommand Build() root.Options.Add(Opt("--username", "Username or email for authentication", recursive: true)); root.Options.Add(Opt("--token", "API token or password for authentication", recursive: true)); root.Options.Add(Opt("--space-key", "Confluence space key", recursive: true)); - root.Options.Add(Opt("--auth-type", "Authentication type: 'onprem' or 'cloud'", recursive: true)); + root.Options.Add(Opt("--auth-type", "Deployment type: 'onprem' or 'cloud' (default: auto-detected from --base-url, *.atlassian.net → cloud)", recursive: true)); root.Options.Add(Flag("--dry-run", "Perform a dry run without writing changes", recursive: true)); root.Options.Add(Flag("--recursive", "Recursively process child pages", recursive: true)); root.Options.Add(Flag("--multi-tree", "Upload update/merge: process every page tree under --source-dir independently (each may live in a different space)", recursive: true)); diff --git a/src/ConfluencePageExporter/Infrastructure/DeploymentTypeResolver.cs b/src/ConfluencePageExporter/Infrastructure/DeploymentTypeResolver.cs new file mode 100644 index 0000000..a44cf90 --- /dev/null +++ b/src/ConfluencePageExporter/Infrastructure/DeploymentTypeResolver.cs @@ -0,0 +1,85 @@ +namespace ConfluencePageExporter.Infrastructure; + +/// +/// Confluence deployment flavour the tool talks to. Determines which REST API +/// family the client must use: Server/Data Center (v1, /rest/api) or +/// Cloud (v2, /wiki/api/v2, with a few operations remaining on v1). +/// +public enum DeploymentType +{ + /// Server / Data Center. + OnPrem, + + /// Atlassian Cloud (*.atlassian.net or a custom domain). + Cloud, +} + +/// +/// Resolves the effective from configuration. +/// An explicit --auth-type / Global:AuthType value wins; +/// otherwise the type is auto-detected from the base URL host +/// (*.atlassian.net ⇒ Cloud). Cloud sites served from a custom domain +/// are not detectable by host, so they need the explicit setting. +/// +public static class DeploymentTypeResolver +{ + public const string OnPremValue = "onprem"; + public const string CloudValue = "cloud"; + + private const string CloudHostSuffix = ".atlassian.net"; + + /// + /// Resolves the deployment type. Throws + /// for an explicit value that is neither nor + /// — a typo here would otherwise silently select + /// the wrong API family. + /// + public static DeploymentType Resolve(string? authType, string? baseUrl) + { + if (string.IsNullOrWhiteSpace(authType)) + return Autodetect(baseUrl); + + if (TryParse(authType, out var type)) + return type; + + throw new ArgumentException( + $"Invalid --auth-type (or Global:AuthType in config) value '{authType}'. " + + $"Expected '{OnPremValue}' or '{CloudValue}'."); + } + + /// Case-insensitive, whitespace-tolerant parse of an explicit value. + public static bool TryParse(string? authType, out DeploymentType type) + { + switch (authType?.Trim().ToLowerInvariant()) + { + case OnPremValue: + type = DeploymentType.OnPrem; + return true; + case CloudValue: + type = DeploymentType.Cloud; + return true; + default: + type = default; + return false; + } + } + + /// + /// Detects Cloud by the well-known Atlassian Cloud host suffix. A missing + /// or unparseable base URL maps to + /// (the historical default) — reporting a missing base URL is the client + /// registration's job, not this resolver's. + /// + public static DeploymentType Autodetect(string? baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + return DeploymentType.OnPrem; + + if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var uri)) + return DeploymentType.OnPrem; + + return uri.Host.EndsWith(CloudHostSuffix, StringComparison.OrdinalIgnoreCase) + ? DeploymentType.Cloud + : DeploymentType.OnPrem; + } +} diff --git a/src/ConfluencePageExporter/Infrastructure/RetryingHttpHandler.cs b/src/ConfluencePageExporter/Infrastructure/RetryingHttpHandler.cs index 36722a6..43b385a 100644 --- a/src/ConfluencePageExporter/Infrastructure/RetryingHttpHandler.cs +++ b/src/ConfluencePageExporter/Infrastructure/RetryingHttpHandler.cs @@ -6,10 +6,15 @@ namespace ConfluencePageExporter.Infrastructure; /// /// that retries transient HTTP failures with -/// exponential backoff. Idempotent verbs only (GET / HEAD / PUT / DELETE / -/// OPTIONS); POST is passed through untouched so we never accidentally -/// duplicate a non-idempotent side effect like "create page" or "upload -/// attachment". +/// exponential backoff, honouring the server's Retry-After header when +/// present (Confluence Cloud sends it with 429 rate-limit and some 503 +/// responses; capped at so a long server-suggested +/// pause can't hang a tool call). Idempotent verbs (GET / HEAD / PUT / DELETE / +/// OPTIONS) retry on any transient failure. POST retries only on 429: +/// a rate limiter rejects the request before it is processed, so a side effect +/// like "create page" or "upload attachment" cannot be duplicated — while on +/// 5xx or a network error the server may have already applied the change, so +/// POST is passed through untouched. /// /// /// Designed for the long-running MCP server: combined with the connection- @@ -21,6 +26,13 @@ namespace ConfluencePageExporter.Infrastructure; /// public sealed class RetryingHttpHandler : DelegatingHandler { + /// + /// Upper bound for a server-suggested Retry-After wait. Anything + /// longer degenerates into an apparent hang for an interactive CLI/MCP + /// call; better to burn the remaining attempts and surface the 429. + /// + internal static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(60); + private readonly ILogger _logger; private readonly int _maxAttempts; private readonly TimeSpan _baseDelay; @@ -54,13 +66,7 @@ public RetryingHttpHandler(ILogger logger) protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - // POST is non-idempotent. We don't know whether the server saw the - // request before the failure, so retrying could create a duplicate - // page or attachment. Pass through unchanged. - if (!IsIdempotent(request.Method)) - { - return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - } + var idempotent = IsIdempotent(request.Method); Exception? lastException = null; for (var attempt = 1; attempt <= _maxAttempts; attempt++) @@ -68,30 +74,41 @@ protected override async Task SendAsync( try { var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - if (!IsTransientStatus(response.StatusCode) || attempt == _maxAttempts) + + // 429 is safe to retry for every verb: the rate limiter + // rejected the request before any side effect happened. + var retryable = IsTransientStatus(response.StatusCode) + && (idempotent || response.StatusCode == HttpStatusCode.TooManyRequests); + if (!retryable || attempt == _maxAttempts) { return response; } + + var delay = RetryAfterDelay(response) ?? NextDelay(attempt); _logger.LogWarning( "HTTP {Method} {Uri} returned transient status {Status}; retrying in {DelayMs}ms ({Attempt}/{Max}).", request.Method.Method, request.RequestUri, (int)response.StatusCode, - NextDelay(attempt).TotalMilliseconds, attempt, _maxAttempts); + delay.TotalMilliseconds, attempt, _maxAttempts); response.Dispose(); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when ( - IsTransient(ex) + idempotent + && IsTransient(ex) && attempt < _maxAttempts && !cancellationToken.IsCancellationRequested) { lastException = ex; + var delay = NextDelay(attempt); _logger.LogWarning( ex, "HTTP {Method} {Uri} threw transient error; retrying in {DelayMs}ms ({Attempt}/{Max}).", request.Method.Method, request.RequestUri, - NextDelay(attempt).TotalMilliseconds, attempt, _maxAttempts); - } + delay.TotalMilliseconds, attempt, _maxAttempts); - await Task.Delay(NextDelay(attempt), cancellationToken).ConfigureAwait(false); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } } // Unreachable in practice: the loop always either returns or rethrows @@ -99,6 +116,28 @@ protected override async Task SendAsync( throw lastException ?? new InvalidOperationException("Retry loop exited without a response."); } + /// + /// Extracts the server-suggested wait from Retry-After (both the + /// delta-seconds and HTTP-date forms), clamped to + /// [0, ]. Returns null when the header + /// is absent, letting the caller fall back to exponential backoff. + /// + internal static TimeSpan? RetryAfterDelay(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter; + if (retryAfter is null) + return null; + + TimeSpan? delay = retryAfter.Delta + ?? (retryAfter.Date is { } date ? date - DateTimeOffset.UtcNow : null); + if (delay is null) + return null; + + if (delay < TimeSpan.Zero) + return TimeSpan.Zero; + return delay > MaxRetryAfter ? MaxRetryAfter : delay; + } + private TimeSpan NextDelay(int attempt) => TimeSpan.FromMilliseconds(_baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1)); diff --git a/src/ConfluencePageExporter/Infrastructure/ServiceCollectionExtensions.cs b/src/ConfluencePageExporter/Infrastructure/ServiceCollectionExtensions.cs index d5894c0..eabdaec 100644 --- a/src/ConfluencePageExporter/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/ConfluencePageExporter/Infrastructure/ServiceCollectionExtensions.cs @@ -67,8 +67,21 @@ public static IServiceCollection AddConfluenceExporterCore(this IServiceCollecti var opts = sp.GetRequiredService>().Value; var baseUrl = opts.BaseUrl ?? throw new ArgumentException("Missing required parameter: --base-url (or Global:BaseUrl in config)"); + // Deployment-type switch point: the Cloud (v2) client plugs in here. + // Until it exists, Cloud mode still gets the v1 client — with a + // warning rather than an error, so an on-prem setup that merely + // set AuthType=cloud by mistake keeps working. + var deployment = DeploymentTypeResolver.Resolve(opts.AuthType, baseUrl); var http = sp.GetRequiredService().CreateClient(ConfluenceClientName); var logger = sp.GetRequiredService>(); + if (deployment == DeploymentType.Cloud) + { + logger.LogWarning( + "Confluence Cloud deployment ({Source}), but Cloud API support is not implemented yet — " + + "using the Server/Data Center (v1) API client. Atlassian removed the v1 content endpoints " + + "from Cloud in 2025, so most operations will fail until Cloud support ships.", + string.IsNullOrWhiteSpace(opts.AuthType) ? "auto-detected from base URL" : "--auth-type cloud"); + } return new HttpClientConfluenceApiClient(baseUrl, http, logger); }); diff --git a/tests/ConfluencePageExporter.Tests/Infrastructure/DeploymentTypeResolverTests.cs b/tests/ConfluencePageExporter.Tests/Infrastructure/DeploymentTypeResolverTests.cs new file mode 100644 index 0000000..65e7ce7 --- /dev/null +++ b/tests/ConfluencePageExporter.Tests/Infrastructure/DeploymentTypeResolverTests.cs @@ -0,0 +1,79 @@ +using ConfluencePageExporter.Infrastructure; +using Shouldly; + +namespace ConfluencePageExporter.Tests.Infrastructure; + +/// +/// Tests for : an explicit --auth-type +/// wins over the URL, unknown values fail loudly, and auto-detection +/// recognises *.atlassian.net hosts only. +/// +public class DeploymentTypeResolverTests +{ + // ── Explicit value ─────────────────────────────────────────────────── + + [Theory] + [InlineData("onprem", DeploymentType.OnPrem)] + [InlineData("ONPREM", DeploymentType.OnPrem)] + [InlineData("cloud", DeploymentType.Cloud)] + [InlineData("Cloud", DeploymentType.Cloud)] + [InlineData(" cloud ", DeploymentType.Cloud)] + public void Resolve_ExplicitValue_WinsOverBaseUrl(string authType, DeploymentType expected) + { + // Both a Cloud-looking and an on-prem-looking URL: the flag decides. + DeploymentTypeResolver.Resolve(authType, "https://example.atlassian.net/wiki").ShouldBe(expected); + DeploymentTypeResolver.Resolve(authType, "https://confluence.corp.local").ShouldBe(expected); + } + + [Theory] + [InlineData("server")] + [InlineData("datacenter")] + [InlineData("cl0ud")] + public void Resolve_UnknownExplicitValue_Throws(string authType) + { + var ex = Should.Throw( + () => DeploymentTypeResolver.Resolve(authType, "https://confluence.corp.local")); + + ex.Message.ShouldContain(authType); + ex.Message.ShouldContain("onprem"); + ex.Message.ShouldContain("cloud"); + } + + // ── Auto-detection ─────────────────────────────────────────────────── + + [Theory] + [InlineData("https://mysite.atlassian.net", DeploymentType.Cloud)] + [InlineData("https://mysite.atlassian.net/", DeploymentType.Cloud)] + [InlineData("https://mysite.atlassian.net/wiki", DeploymentType.Cloud)] + [InlineData("https://MYSITE.ATLASSIAN.NET/wiki/", DeploymentType.Cloud)] + [InlineData("https://team.dev.atlassian.net", DeploymentType.Cloud)] + [InlineData("https://atlassian.net", DeploymentType.OnPrem)] // apex is not a site + [InlineData("https://notatlassian.net", DeploymentType.OnPrem)] // suffix must match on a dot + [InlineData("https://evil.example.com/?q=.atlassian.net", DeploymentType.OnPrem)] + [InlineData("https://confluence.corp.local/confluence", DeploymentType.OnPrem)] + [InlineData(null, DeploymentType.OnPrem)] + [InlineData("", DeploymentType.OnPrem)] + [InlineData(" ", DeploymentType.OnPrem)] + [InlineData("not a url", DeploymentType.OnPrem)] + public void Autodetect_RecognisesCloudHostsOnly(string? baseUrl, DeploymentType expected) => + DeploymentTypeResolver.Autodetect(baseUrl).ShouldBe(expected); + + [Fact] + public void Resolve_WithoutExplicitValue_FallsBackToAutodetect() + { + DeploymentTypeResolver.Resolve(null, "https://mysite.atlassian.net/wiki").ShouldBe(DeploymentType.Cloud); + DeploymentTypeResolver.Resolve("", "https://confluence.corp.local").ShouldBe(DeploymentType.OnPrem); + DeploymentTypeResolver.Resolve(" ", null).ShouldBe(DeploymentType.OnPrem); + } + + // ── TryParse ───────────────────────────────────────────────────────── + + [Theory] + [InlineData("onprem", true)] + [InlineData("cloud", true)] + [InlineData("wrong", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void TryParse_AcceptsOnlyKnownValues(string? value, bool expected) => + DeploymentTypeResolver.TryParse(value, out _).ShouldBe(expected); +} diff --git a/tests/ConfluencePageExporter.Tests/Infrastructure/RetryingHttpHandlerTests.cs b/tests/ConfluencePageExporter.Tests/Infrastructure/RetryingHttpHandlerTests.cs index 9d1e7eb..5f45645 100644 --- a/tests/ConfluencePageExporter.Tests/Infrastructure/RetryingHttpHandlerTests.cs +++ b/tests/ConfluencePageExporter.Tests/Infrastructure/RetryingHttpHandlerTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Sockets; using ConfluencePageExporter.Infrastructure; using Shouldly; @@ -11,7 +12,7 @@ namespace ConfluencePageExporter.Tests.Infrastructure; /// top of an , plug a /// beneath it that produces controlled responses/exceptions per attempt, and /// assert the retry contract: transient → retry, non-transient → propagate, -/// POST → never retry, cancellation → abort. +/// POST → retry only on 429, Retry-After honoured, cancellation → abort. /// public class RetryingHttpHandlerTests { @@ -158,6 +159,101 @@ public async Task SendAsync_ShouldNotRetry_OnPost_EvenFor503() inner.AttemptCount.ShouldBe(1); } + // ── Rate limiting: POST + 429, Retry-After ─────────────────────────── + + [Fact] + public async Task SendAsync_ShouldRetryPost_On429_ThenSucceed() + { + // 429 comes from the rate limiter, which rejects the request before + // any side effect happens — the one transient status POST retries on. + var inner = new StubInnerHandler(attempt => attempt < 2 + ? new HttpResponseMessage(HttpStatusCode.TooManyRequests) + : new HttpResponseMessage(HttpStatusCode.OK)); + using var client = new HttpClient(Build(inner)); + + var response = await client.PostAsync("http://example.com/", new StringContent("{}"), TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + inner.AttemptCount.ShouldBe(2); + } + + [Fact] + public async Task SendAsync_ShouldReturnLast429_WhenPostStaysRateLimited() + { + var inner = new StubInnerHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)); + using var client = new HttpClient(Build(inner, max: 3)); + + var response = await client.PostAsync("http://example.com/", new StringContent("{}"), TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); + inner.AttemptCount.ShouldBe(3); + } + + [Fact] + public async Task SendAsync_ShouldRetry_UsingRetryAfterHeader() + { + // Retry-After: 0 → immediate retry; proves the header path is taken + // without slowing the suite down. + var inner = new StubInnerHandler(attempt => + { + if (attempt >= 2) + return new HttpResponseMessage(HttpStatusCode.OK); + var rateLimited = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + rateLimited.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.Zero); + return rateLimited; + }); + using var client = new HttpClient(Build(inner)); + + var response = await client.GetAsync("http://example.com/", TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + inner.AttemptCount.ShouldBe(2); + } + + [Fact] + public void RetryAfterDelay_ShouldUseDeltaSeconds() + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(5)); + + RetryingHttpHandler.RetryAfterDelay(response).ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void RetryAfterDelay_ShouldClampLongDelta_ToMaximum() + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromMinutes(10)); + + RetryingHttpHandler.RetryAfterDelay(response).ShouldBe(RetryingHttpHandler.MaxRetryAfter); + } + + [Fact] + public void RetryAfterDelay_ShouldClampFarFutureDate_ToMaximum() + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new RetryConditionHeaderValue(DateTimeOffset.UtcNow.AddMinutes(10)); + + RetryingHttpHandler.RetryAfterDelay(response).ShouldBe(RetryingHttpHandler.MaxRetryAfter); + } + + [Fact] + public void RetryAfterDelay_ShouldTreatPastDate_AsZero() + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new RetryConditionHeaderValue(DateTimeOffset.UtcNow.AddMinutes(-5)); + + RetryingHttpHandler.RetryAfterDelay(response).ShouldBe(TimeSpan.Zero); + } + + [Fact] + public void RetryAfterDelay_ShouldReturnNull_WhenHeaderAbsent() + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + + RetryingHttpHandler.RetryAfterDelay(response).ShouldBeNull(); + } + // ── Cancellation ───────────────────────────────────────────────────── [Fact]