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
34 changes: 34 additions & 0 deletions .cursor/rules/markdown-list-markers.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
description: Use asterisk markers for Markdown unordered list bullets
globs: **/*.md
alwaysApply: false
---

# Markdown list bullets

In Markdown files, mark **unordered** list items with asterisks (`*`), not hyphens (`-`) or plus signs (`+`).

## Rules

* Use `*` for every unordered list bullet, including nested items under numbered steps.
* Preserve existing indentation (typically four spaces per nesting level under numbered lists).
* Ordered lists (`1.`, `2.`, …) are unchanged.
* When editing or adding list items, convert any `-` or `+` bullets you touch to `*`.

## Examples

```markdown
<!-- Prefer -->
* First item
* Second item
* Nested item

1. Step one:
* Detail A
* Detail B

<!-- Avoid -->
- First item
- Second item
- Nested item
```
40 changes: 40 additions & 0 deletions .cursor/rules/prose-punctuation.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
description: Avoid semicolons and em dashes in prose and documentation
alwaysApply: true
---

# Prose punctuation

In user-facing text (documentation, comments, commit messages, PR descriptions, and assistant replies), avoid semicolons and em dashes.

## Semicolons

Do not use `;` to join clauses in prose. Prefer separate sentences, a comma with a conjunction, or a short list.

```markdown
<!-- Avoid -->
Compose defaults to `false`; when disabled, jobs only sleep for the requested duration.

<!-- Prefer -->
Compose defaults to `false`. When disabled, jobs only sleep for the requested duration.
```

This rule does **not** apply to code syntax. Keep semicolons where the language requires them (for example C# statement terminators).

## Em dashes

Do not use em dashes (`—`) or spaced hyphen em-dash stand-ins (` - ` used as a break). Prefer periods, commas, parentheses, or a colon.

```markdown
<!-- Avoid -->
The connector retries indefinitely—until the job is cancelled.

<!-- Prefer -->
The connector retries indefinitely until the job is cancelled.
```

Hyphens in compound words (`client-credentials`) and en dashes in ranges (`1–5`) are fine.

## When editing existing text

When you touch documentation or comments, rewrite nearby semicolon- or em-dash-heavy phrasing into the preferred style.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ BenchmarkDotNet.Artifacts/

# Editor
.vscode/
.cursor/

# Python
__pycache__/
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ Repo features:
the message source.
* Prevents simultaneous execution of the same message in the event of a dropped message
* Caches results to prevent re-running of a job if received non-concurrently
* Container health probes
* Documentation for local testing (see `test/local/`)
* Container health probes.
* Sample API connector that respects rate limit responses.
* For more information, see [`docs/bar-connector.md`](docs/bar-connector.md).
* Documentation for local testing (see `test/local/`).

# Core Architecture

Expand Down Expand Up @@ -420,6 +422,8 @@ Below are the recommended steps for using this as a template:
* The dependency injection setup in the root project assumes that the general template will be pruned down.
* The dependency injection setup in the root project assumes that the chosen Secret Manager is SSM unless the chosen
job source is explicitly Azure-based (see below for more details).
6. Consider revising/pruning the Markdown files such as this README or those located in the `docs/` directory. They
assume that they are speaking for a general template and not for an applied application.

## Cached Idempotency vs Database

Expand Down
15 changes: 15 additions & 0 deletions RedShirt.Example.JobWorker.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
<File Path="Dockerfile"/>
<File Path="README.md"/>
</Folder>
<Folder Name="/Solution Items/docs/">
<File Path="docs\bar-connector.md"/>
</Folder>
<Folder Name="/src/">
<Project
Path="src/RedShirt.Example.JobWorker.Common.Aws.SsmSecretManager/RedShirt.Example.JobWorker.Common.Aws.SsmSecretManager.csproj"/>
Expand All @@ -25,6 +28,12 @@
<Project
Path="src/RedShirt.Example.JobWorker.Common.SecretManagers.Core/RedShirt.Example.JobWorker.Common.SecretManagers.Core.csproj"/>
<Project Path="src/RedShirt.Example.JobWorker.Common/RedShirt.Example.JobWorker.Common.csproj"/>
<Project
Path="src/RedShirt.Example.JobWorker.Connectors.Bar.Core/RedShirt.Example.JobWorker.Connectors.Bar.Core.csproj"/>
<Project
Path="src/RedShirt.Example.JobWorker.Connectors.Bar.Implementation/RedShirt.Example.JobWorker.Connectors.Bar.Implementation.csproj"/>
<Project
Path="src/RedShirt.Example.JobWorker.Connectors.Common.Http/RedShirt.Example.JobWorker.Connectors.Common.Http.csproj"/>
<Project Path="src/RedShirt.Example.JobWorker.Core.Logic/RedShirt.Example.JobWorker.Core.Logic.csproj"/>
<Project Path="src/RedShirt.Example.JobWorker.Core/RedShirt.Example.JobWorker.Core.csproj"/>
<Project
Expand Down Expand Up @@ -72,6 +81,12 @@
Path="test/RedShirt.Example.JobWorker.Common.SecretManagers.Core.UnitTests/RedShirt.Example.JobWorker.Common.SecretManagers.Core.UnitTests.csproj"/>
<Project
Path="test/RedShirt.Example.JobWorker.Common.UnitTests/RedShirt.Example.JobWorker.Common.UnitTests.csproj"/>
<Project
Path="test/RedShirt.Example.JobWorker.Connectors.Bar.Core.UnitTests/RedShirt.Example.JobWorker.Connectors.Bar.Core.UnitTests.csproj"/>
<Project
Path="test/RedShirt.Example.JobWorker.Connectors.Bar.Implementation.UnitTests/RedShirt.Example.JobWorker.Connectors.Bar.Implementation.UnitTests.csproj"/>
<Project
Path="test/RedShirt.Example.JobWorker.Connectors.Common.Http.UnitTests/RedShirt.Example.JobWorker.Connectors.Common.Http.UnitTests.csproj"/>
<Project
Path="test/RedShirt.Example.JobWorker.Core.Logic.UnitTests/RedShirt.Example.JobWorker.Core.Logic.UnitTests.csproj"/>
<Project
Expand Down
62 changes: 62 additions & 0 deletions docs/bar-connector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Bar connector

The Bar connector projects (`Connectors.Bar.Core` and `Connectors.Bar.Implementation`) are a placeholder for an
OAuth-backed HTTP API, such as the [RedShirt.Example.Api](https://github.com/adeutscher/RedShirt.Example.Api) template.
They are a generic placeholder for a real API client that a JobWorker uses to perform long-running or downstream work
against another service.

## OAuth by default

This sample connector assumes OAuth 2.0 client-credentials authentication. Authentication in this JobWorker template was
made with OAuth in mind because it supports the authorization model that
the [API Template](https://github.com/adeutscher/RedShirt.Example.Api) is set up for.

If you are planning to use this template with an API that instead uses static keys, the request handler implementation
should be pivoted to be more like the `FooConnector` in
the [API Template](https://github.com/adeutscher/RedShirt.Example.Api).

## Rate limits and reasons to wait

The client respects `BarReasonToWaitException` (including its inheritors `BarRateLimitedException` and
`BarTemporarilyUnavailableException`, both defined in `Connectors.Bar.Implementation`) **indefinitely**. When a call
receives a reason to wait, the connector delays using `ISleepService` for the value of `RetryAfter` when present, or a
configurable fallback (default **15 seconds**
when that fallback is itself `null`). It then retries until the operation succeeds or the job's cancellation token is
triggered. Cancellation and overall job lifetime are the Core job worker configuration's concern. The Bar connector's
duty is to keep trying respectfully when rate limited or otherwise told to wait.

## Last-mile instructions

These steps cannot be fully performed in a general template. Adapt them for your target API.

### If you are using the [API Template](https://github.com/adeutscher/RedShirt.Example.Api)

1. Adjust your API implementation to publish an interop package to a NuGet repository, for example:
* [Azure DevOps Artifacts](https://learn.microsoft.com/en-us/azure/devops/artifacts/nuget/publish)
* [GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-nuget-registry)
* [Sonatype Nexus Repository](https://help.sonatype.com/en/nuget-repositories.html)
2. Rename `Bar.Core` and `Bar.Implementation` projects as appropriate for your target API.
3. Reference the interop NuGet package in your renamed implementation project.
4. Write wrapper clients for the relevant clients from the interop package that inspect thrown `SwaggerException`
instances:
* `SwaggerException` instances with an HTTP **429** status code (Too Many Requests) should specifically become a
`BarRateLimitedException` with the value of the `Retry-After` header.
* Confirm that the exception arbiter (`BarExceptionArbiterService`) treats other status code values appropriately.
5. Adjust your client factory to return the wrapper client.
6. Rename classes whose names begin with `Bar` as appropriate for your target API.

### If you are not using the [API Template](https://github.com/adeutscher/RedShirt.Example.Api) or other OpenAPI package

If you are not using the API template or another flavour of OpenAPI/Swagger-generated package, then the existing Bar
connector example might already be closer to your needs:

1. Rename `Bar.Core` and `Bar.Implementation` projects as appropriate for your target API.
2. Confirm that the exception arbiter treats the appropriate return codes appropriately.
3. In the client response handler, confirm that an HTTP **429** should specifically become a `BarRateLimitedException`
with the value of the `Retry-After` header.
4. Rename classes whose names begin with `Bar` as appropriate for your target API.

## Local testing

See `test/local/readme.md` for WireMock Bar stubs, SSM/Key Vault credential paths, and OAuth rotation scripts under
`test/local/scripts/wiremock-bar/`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace RedShirt.Example.JobWorker.Connectors.Bar.Core.Exceptions;

/// <summary>
/// Classified failure from a Bar connector operation. Thrown by the connector implementation after
/// retry/arbitration so callers can react to a stable, already-handled outcome.
/// </summary>
public class BarException : Exception
{
/// <summary>
/// When <c>true</c>, a retry wrapper inside the connector layer has already exhausted retries for the
/// underlying cause; outer retry layers should not retry again.
/// </summary>
public bool IsHandled { get; init; }

/// <summary>
/// When <c>true</c>, suggests a possible transient or environmental cause could be resolved outside the application
/// process (with an infrastructure change, for example) without restarting the application.
/// </summary>
public bool CouldBeTransient { get; init; }

/// <summary>
/// When <c>true</c>, suggests a possible environmental cause that could be resolved outside the application
/// process (for example an infrastructure change) without restarting the application.
/// </summary>
public bool CouldBeExternallySolvable { get; init; }

public BarException(Exception innerException) : base(innerException.Message, innerException)
{
}

public BarException(string message) : base(message)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace RedShirt.Example.JobWorker.Connectors.Bar.Core.Exceptions;

/// <summary>
/// The Bar dependency reported that no record exists for the requested id (HTTP 404).
/// </summary>
public sealed class BarRecordNotFoundException(int id) : Exception($"Bar record {id} was not found.")
{
public int Id => id;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace RedShirt.Example.JobWorker.Connectors.Bar.Core.Models;

public sealed class CreateBarConnectorRequest
{
public required string Name { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RedShirt.Example.JobWorker.Connectors.Bar.Core.Models;

public sealed class CreateBarConnectorResponse
{
public required int Id { get; init; }

public required string Name { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RedShirt.Example.JobWorker.Connectors.Bar.Core.Models;

public sealed class GetBarConnectorResponse
{
public required int Id { get; init; }

public required string Name { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using RedShirt.Example.JobWorker.Connectors.Bar.Core.Models;

namespace RedShirt.Example.JobWorker.Connectors.Bar.Core.Services;

/// <summary>
/// Opaque connector for the Bar dependency.
/// Bar is a stand-in for an OAuth-backed API client; see <c>docs/bar-connector.md</c> for last-mile instructions.
/// </summary>
public interface IBarConnector
{
Task<CreateBarConnectorResponse> CreateAsync(CreateBarConnectorRequest request,
CancellationToken cancellationToken = default);

Task<GetBarConnectorResponse> GetByIdAsync(int id, CancellationToken cancellationToken = default);
}
Loading