Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/merge-bot-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion .github/workflows/run-codegen-pull-request-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ jobs:
codegen:
name: Run codegen and pull request job
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:

Expand Down Expand Up @@ -38,12 +41,22 @@ 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'
body: 'This PR updates the codegen files.'
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 }}
5 changes: 5 additions & 0 deletions CodeGen/ApiNinjas.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -18,6 +20,9 @@ internal async Task<string> 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
Expand Down
94 changes: 71 additions & 23 deletions CodeGen/HttpClientFactory.cs
Original file line number Diff line number Diff line change
@@ -1,68 +1,116 @@
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<HttpClient> s_httpClient = new(CreateHttpClient);
private static readonly Lazy<ResilienceHandler> s_resilienceHandler = new(
CreateResilienceHandler
);

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<HttpResponseMessage>()
.AddRetry(
new Polly.Retry.RetryStrategyOptions<HttpResponseMessage>
new RetryStrategyOptions<HttpResponseMessage>
{
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<HttpResponseMessage>
new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
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<HttpResponseMessage> 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;
}
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading