Skip to main content

Layering and Decorators

Layers add cross-cutting behavior around any provider. They implement IVcsProviderLayer, wrap an IVcsProvider, and forward every operation.

using VcsManager;
using VcsManager.Builder;

var provider = new VcsProviderBuilder()
.WithGitHub(options => options.Token = token)
.WithRetry()
.WithTimeout(options => options.Timeout = TimeSpan.FromSeconds(5))
.WithConcurrentLimiter(options => options.MaxConcurrentRequests = 5)
.WithLogging(options => options.ProviderName = "github")
.WithOtelMetrics()
.WithOtelTrace()
.Build();

Order matters​

VcsProviderBuilder applies layers in registration order. The first layer added is the outermost wrapper.

var provider = new VcsProviderBuilder()
.WithGitHub(options => options.Token = token)
.WithRetry() // outermost
.WithTimeout() // applied to each retry attempt
.Build();

If timeout is added first, it becomes the outermost layer and limits the total operation, including all retry attempts:

.WithTimeout()
.WithRetry() // inside the timeout

Choose the order that matches the policy you need.

Retry​

var provider = new VcsProviderBuilder()
.WithGitHub(options => options.Token = token)
.WithRetry(options =>
{
options.MaxAttempts = 5;
options.BaseDelay = TimeSpan.FromMilliseconds(100);
options.MaxDelay = TimeSpan.FromSeconds(2);
options.JitterFactor = 0.2;
options.RetryPredicate = exception => exception is not VcsAuthorizationException;
})
.Build();
OptionDefaultDescription
MaxAttempts3Total attempts, including the first.
BaseDelay100 msBase delay for exponential backoff.
MaxDelay2 sMaximum delay between attempts.
JitterFactor0.2Random jitter from 0 to 1.
RetryPredicatenullOptional veto. It cannot make a non-retryable failure retryable.

Retry behavior:

  • Rate limits are retried. VcsRateLimitException.RetryAfter is used as the delay when present.
  • Transport failures and HTTP 5xx responses are retried. Providers mark those mapped VcsProviderException instances with IsTransient = true. A VcsProviderException with an inner HttpRequestException that has a 429 or 5xx status is also retried.
  • VcsConflictException and VcsUnsupportedOperationException are not retried.
  • Canceled operations are not retried.
  • Merge is never retried because it is not automatically idempotent.
  • Create repository, seed repository, delete repository, and create pull request are not retried because a retry can duplicate the operation.
  • A negative RetryAfter is treated as no delay.
  • Read operations and close are retried. Close is idempotent by contract.
  • A successful operation returns immediately; the layer does not retry it.

Timeout​

var provider = new VcsProviderBuilder()
.WithGitLab(options => options.Token = token)
.WithTimeout(options => options.Timeout = TimeSpan.FromSeconds(5))
.Build();

TimeoutLayer applies a deadline to each provider operation. When the deadline expires and the caller did not cancel the operation, it throws VcsProviderException with IsTransient = true and an inner TimeoutException. The default timeout is 10 seconds.

Concurrency limiter​

var provider = new VcsProviderBuilder()
.WithAzureDevOps(options => options.PersonalAccessToken = token)
.WithConcurrentLimiter(options =>
{
options.MaxConcurrentRequests = 5;
options.AcquireTimeout = TimeSpan.FromMilliseconds(200);
})
.Build();
OptionDefaultDescription
MaxConcurrentRequests10Maximum operations that run at the same time.
AcquireTimeoutnullMaximum wait for a slot. null waits indefinitely.

When AcquireTimeout expires, the layer throws VcsProviderException with IsTransient = true and an inner TimeoutException.

When AcquireTimeout expires, the layer throws TimeoutException unless the caller canceled the operation.

Logging​

public sealed class ConsoleVcsLogger : IVcsProviderLogger
{
public void LogStart(VcsProviderLogContext context) =>
Console.WriteLine($"{context.ProviderName} {context.Operation} start");

public void LogSuccess(VcsProviderLogContext context, TimeSpan duration) =>
Console.WriteLine($"{context.Operation} success in {duration}");

public void LogFailure(VcsProviderLogContext context, TimeSpan duration, string exceptionType) =>
Console.WriteLine($"{context.Operation} failed with {exceptionType}");
}

var provider = new VcsProviderBuilder()
.WithGitea(options => options.BaseUrl = baseUrl)
.WithLogging(options =>
{
options.ProviderName = "gitea";
options.Logger = new ConsoleVcsLogger();
})
.Build();

VcsProviderLogContext contains the provider name, operation, optional repository display name, optional pull request number, and outcome. It never contains tokens, authorization headers, request bodies, or exception messages. NullVcsProviderLogger ignores every event and is the default.

OpenTelemetry metrics​

var provider = new VcsProviderBuilder()
.WithGitHub(options => options.Token = token)
.WithOtelMetrics(options =>
{
options.MeterName = "my-app.vcs";
options.MeterVersion = "1.0.0";
options.MetricPrefix = "vcs.";
options.ProviderName = "github";
})
.Build();

The layer emits:

MetricTypeDescription
{prefix}operation.callsCounterProvider operations started.
{prefix}operation.failuresCounterFailed provider operations.
{prefix}operation.duration_msHistogramOperation duration in milliseconds.

Tags include provider, operation, optional repository, optional pull_request, and outcome (success or failure).

OpenTelemetry trace​

var provider = new VcsProviderBuilder()
.WithGitLab(options => options.Token = token)
.WithOtelTrace(options =>
{
options.ActivitySourceName = "my-app.vcs";
options.ProviderName = "gitlab";
options.RecordException = true;
})
.Build();

Each call creates a client activity named after the operation. Tags include vcs.provider, vcs.operation, optional vcs.repository, and optional vcs.pull_request.number. Failures set an error status with the exception type; exception messages are never recorded.

Custom layers​

Implement IVcsProviderLayer and wrap the provider:

public sealed class AuditLayer : IVcsProviderLayer
{
public IVcsProvider Wrap(IVcsProvider inner) => new AuditProvider(inner);
}

public sealed class AuditProvider(IVcsProvider inner) : VcsProviderDecorator(inner)
{
public override async Task<PullRequest> MergePullRequestAsync(
RepositoryRef repository,
int number,
MergePullRequestRequest? request = null,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"Merging #{number} in {repository.Name}");
return await Inner.MergePullRequestAsync(repository, number, request, cancellationToken);
}
}

Register the layer with AddLayer:

var provider = new VcsProviderBuilder()
.WithGitHub(options => options.Token = token)
.AddLayer(new AuditLayer())
.Build();