diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 36b61cd..39c1a65 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -36,7 +36,7 @@ jobs: merge-codegen: name: Merge codegen pull request job runs-on: ubuntu-latest - if: github.actor == 'ptr727' && github.event.pull_request.user.login == 'ptr727' && github.event.pull_request.head.ref == 'codegen' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.repo.full_name == github.repository + if: github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.ref == 'codegen' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.repo.full_name == github.repository && ((github.event.action == 'reopened' && github.actor == 'ptr727') || (github.event.action != 'reopened' && github.actor == 'github-actions[bot]')) permissions: contents: write pull-requests: write diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/.github/workflows/run-codegen-pull-request-task.yml index 8c58bc6..fdcd8df 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -11,6 +11,9 @@ jobs: codegen: name: Run codegen and pull request job runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: @@ -38,8 +41,9 @@ jobs: - name: Create pull request step uses: peter-evans/create-pull-request@v8 + id: cpr with: - token: ${{ secrets.WORKFLOW_PAT }} + token: ${{ secrets.GITHUB_TOKEN }} base: main branch: codegen title: 'Update codegen files' @@ -47,3 +51,12 @@ jobs: commit-message: 'Update codegen files' delete-branch: true sign-commits: true + + - name: Trigger PR workflows step + if: steps.cpr.outputs.pull-request-number != '' + run: | + PR="${{ steps.cpr.outputs.pull-request-number }}" + gh pr close "$PR" + gh pr reopen "$PR" + env: + GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} diff --git a/CodeGen/ApiNinjas.cs b/CodeGen/ApiNinjas.cs index 1a68262..98b665c 100644 --- a/CodeGen/ApiNinjas.cs +++ b/CodeGen/ApiNinjas.cs @@ -1,4 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; +using System.Net.Mime; using System.Text.Json; using System.Text.Json.Serialization; @@ -18,6 +20,9 @@ internal async Task GetQuoteOfTheDayAsync() HttpMethod.Get, "https://api.api-ninjas.com/v2/quotes?categories=philosophy" ); + request.Headers.Accept.Add( + new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json) + ); request.Headers.Add("X-Api-Key", apiKey); using HttpResponseMessage response = await HttpClientFactory diff --git a/CodeGen/HttpClientFactory.cs b/CodeGen/HttpClientFactory.cs index d16452d..121098f 100644 --- a/CodeGen/HttpClientFactory.cs +++ b/CodeGen/HttpClientFactory.cs @@ -1,11 +1,31 @@ using System.Net.Http.Headers; using Microsoft.Extensions.Http.Resilience; using Polly; +using Polly.CircuitBreaker; +using Polly.Retry; namespace ptr727.ProjectTemplate.CodeGen; internal static class HttpClientFactory { + // Retry + private const int RetryMaxAttempts = 3; + private static readonly TimeSpan s_retryBaseDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_retryMaxDelay = TimeSpan.FromSeconds(30); + + // Circuit breaker + private const double CircuitBreakerFailureRatio = 0.1; + private const int CircuitBreakerMinimumThroughput = 10; + private static readonly TimeSpan s_circuitBreakerSamplingDuration = TimeSpan.FromSeconds(60); + private static readonly TimeSpan s_circuitBreakerBreakDuration = TimeSpan.FromSeconds(30); + + // Connection pool + private static readonly TimeSpan s_connectionLifetime = TimeSpan.FromMinutes(15); + private static readonly TimeSpan s_connectionIdleTimeout = TimeSpan.FromMinutes(2); + + // HttpClient + private static readonly TimeSpan s_httpClientTimeout = TimeSpan.FromSeconds(120); + private static readonly Lazy s_httpClient = new(CreateHttpClient); private static readonly Lazy s_resilienceHandler = new( CreateResilienceHandler @@ -13,56 +33,84 @@ internal static class HttpClientFactory internal static HttpClient GetHttpClient() => s_httpClient.Value; - internal static ResilienceHandler GetResilienceHandler() => s_resilienceHandler.Value; + private static ResilienceHandler GetResilienceHandler() => s_resilienceHandler.Value; private static ResilienceHandler CreateResilienceHandler() => new( new ResiliencePipelineBuilder() .AddRetry( - new Polly.Retry.RetryStrategyOptions + new RetryStrategyOptions { - MaxRetryAttempts = 3, + MaxRetryAttempts = RetryMaxAttempts, BackoffType = DelayBackoffType.Exponential, UseJitter = true, - Delay = TimeSpan.FromSeconds(1), - MaxDelay = TimeSpan.FromSeconds(30), + Delay = s_retryBaseDelay, + MaxDelay = s_retryMaxDelay, ShouldHandle = args => - ValueTask.FromResult( - args.Outcome.Exception != null - || args.Outcome.Result is { IsSuccessStatusCode: false } - ), + ValueTask.FromResult(IsTransientFailure(args.Outcome)), + OnRetry = args => + { + Log.Logger.Warning( + "HTTP retry attempt {Attempt} after {Delay}ms: {Outcome}", + args.AttemptNumber, + args.RetryDelay.TotalMilliseconds, + args.Outcome + ); + return ValueTask.CompletedTask; + }, } ) .AddCircuitBreaker( - new Polly.CircuitBreaker.CircuitBreakerStrategyOptions + new CircuitBreakerStrategyOptions { - FailureRatio = 0.2, - MinimumThroughput = 10, - SamplingDuration = TimeSpan.FromSeconds(60), - BreakDuration = TimeSpan.FromSeconds(30), + FailureRatio = CircuitBreakerFailureRatio, + MinimumThroughput = CircuitBreakerMinimumThroughput, + SamplingDuration = s_circuitBreakerSamplingDuration, + BreakDuration = s_circuitBreakerBreakDuration, ShouldHandle = args => - ValueTask.FromResult( - args.Outcome.Exception != null - || args.Outcome.Result is { IsSuccessStatusCode: false } - ), + ValueTask.FromResult(IsTransientFailure(args.Outcome)), + OnOpened = args => + { + Log.Logger.Warning( + "Circuit breaker opened for {Duration}s: {Outcome}", + args.BreakDuration.TotalSeconds, + args.Outcome + ); + return ValueTask.CompletedTask; + }, + OnClosed = _ => + { + Log.Logger.Information("Circuit breaker closed."); + return ValueTask.CompletedTask; + }, + OnHalfOpened = _ => + { + Log.Logger.Debug("Circuit breaker half-opened."); + return ValueTask.CompletedTask; + }, } ) - .AddTimeout(TimeSpan.FromSeconds(30)) .Build() ) { InnerHandler = new SocketsHttpHandler { - PooledConnectionLifetime = TimeSpan.FromMinutes(15), - PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + PooledConnectionLifetime = s_connectionLifetime, + PooledConnectionIdleTimeout = s_connectionIdleTimeout, + AutomaticDecompression = System.Net.DecompressionMethods.All, }, }; + private static bool IsTransientFailure(Outcome outcome) => + outcome.Exception is not null + ? outcome.Exception is not (OperationCanceledException or BrokenCircuitException) + : outcome.Result is not null && (int)outcome.Result.StatusCode is 408 or 429 or >= 500; + private static HttpClient CreateHttpClient() { - HttpClient httpClient = new(GetResilienceHandler()) { Timeout = TimeSpan.FromSeconds(120) }; + HttpClient httpClient = new(GetResilienceHandler()) { Timeout = s_httpClientTimeout }; httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue(AssemblyInfo.AppName, AssemblyInfo.InformationalVersion) + new ProductInfoHeaderValue(AssemblyInfo.AppName, AssemblyInfo.ReleaseVersion) ); return httpClient; } diff --git a/README.md b/README.md index cc02858..da04ae1 100644 --- a/README.md +++ b/README.md @@ -385,16 +385,17 @@ Licensed under the [MIT License][license-link]\ - Create a [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens). - Save the PAT as `WORKFLOW_PAT`. - Permissions: - - Contents: Read & write (to push the codegen commit) - - Pull requests: Read & write — to create the PR - - Workflows: Read & write — this is the key permission that allows the token to trigger `pull_request` events in other workflows; without it the auto-merge workflow never fires + - Pull requests: Read & write — to close and reopen the PR, triggering `pull_request` workflow events under the PAT owner's identity + - Workflows: Read & write — required for the PAT to trigger `pull_request` events in other workflows - Metadata: Read-only (auto-required) + - The codegen workflow uses `GITHUB_TOKEN` to create a signed commit and open the PR as `github-actions[bot]`. It then uses `WORKFLOW_PAT` to close and reopen the PR so the `pull_request` event fires under the PAT owner's identity (`ptr727`), which triggers the auto-merge workflow. PRs created or updated by `GITHUB_TOKEN` alone do not trigger other workflows, hence the close/reopen step. - The auto-merge condition in `merge-bot-pull-request.yml` requires all of the following to be true: - - `github.actor == 'ptr727'` — the event was triggered by the PAT owner (guards against a collaborator pushing to the branch after PR creation) - - `github.event.pull_request.user.login == 'ptr727'` — the PR was created by the PAT owner + - `github.event.pull_request.user.login == 'github-actions[bot]'` — the PR was created by the Actions bot (via `GITHUB_TOKEN`) - `github.event.pull_request.head.ref == 'codegen'` — the source branch is `codegen` - `github.event.pull_request.base.ref == 'main'` — the PR targets `main` - `github.event.pull_request.head.repo.full_name == github.repository` — the PR is from the same repository (not a fork) + - For `reopened` events: `github.actor == 'ptr727'` — the reopen was triggered by the PAT owner + - For all other events: `github.actor == 'github-actions[bot]'` — triggered by normal workflow activity - Save the PAT as `WORKFLOW_PAT` in: - GitHub project security Settings / Secrets / Actions.