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
7 changes: 4 additions & 3 deletions SW.Bitween.Client/ExchangeQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,10 @@ public sealed class ExchangeSelection
/// <summary>Every exchange <paramref name="query"/> matches, but those in <paramref name="except"/>.</summary>
public static ExchangeSelection Matching(ExchangeQuery query, IEnumerable<string>? except = null)
{
// No ids adds no filter, so it would select every exchange rather than none.
if (query.Ids is { Count: 0 })
throw new ArgumentException("Ids is empty: it would match every exchange.", nameof(query));
// An empty list adds no filter, so it would widen the selection rather than narrow it.
if (query.Ids is { Count: 0 } || query.PartnerIds is { Count: 0 } || query.SubscriptionIds is { Count: 0 }
|| query.InformationTypeIds is { Count: 0 })
throw new ArgumentException("An id list is empty: it would widen the selection, not narrow it.", nameof(query));
var filters = query.ToFilters();
// An empty filter reads as no filter, and the selection would fall back to its ids, which are
// none. Every attempt, not only the newest, matches everything without narrowing it.
Expand Down
2 changes: 1 addition & 1 deletion SW.Bitween.Client/Exchanges.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ await bitween.PostAsync($"api/xchanges/{Uri.EscapeDataString(id)}/retry",
var retry = await SearchAsync(new ExchangeQuery { RetryFor = id }, 0, 1, false, cancellation);
return retry.Result.FirstOrDefault()?.Id;
}
catch (Exception e) when (e is BitweenClientException or HttpRequestException)
catch (Exception e) when (e is not OperationCanceledException || !cancellation.IsCancellationRequested)
{
return null;
}
Expand Down
24 changes: 17 additions & 7 deletions SW.Bitween.HttpTests/BitweenClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ await fixture.InDbAsync(db => db.Set<Domain.XchangeResult>().Where(r => ids.Cont
Assert.Equal(2, preview.WillRetry);
Assert.Equal(3, (await bitween.Exchanges.SearchAsync(partners)).TotalCount);

// No ids would read as no filter, and select every exchange.
// An empty list would read as no filter, and widen the selection.
Assert.Throws<ArgumentException>(() => ExchangeSelection.Matching(new ExchangeQuery { Ids = [] }));
Assert.Throws<ArgumentException>(() => ExchangeSelection.Matching(new ExchangeQuery { Status = ExchangeStatus.Failed, PartnerIds = [] }));

var plan = await bitween.Exchanges.BulkRetryAsync(ExchangeSelection.Of(ids.Take(2)));
Assert.Equal(2, plan.WillRetry);
Expand Down Expand Up @@ -184,8 +185,10 @@ public async Task Says_what_Bitween_refused_and_why()
using var address = fixture.App.CreateDefaultClient(new Uri("https://localhost"), signIns);
address.DefaultRequestHeaders.Add("X-Forwarded-For", $"10.{Random.Shared.Next(255)}.{Random.Shared.Next(255)}.{Random.Shared.Next(1, 255)}");
using var stranger = new BitweenClient(address, "bwt_" + new string('x', 40));
var refusals = await Task.WhenAll(Enumerable.Range(0, 10)
.Select(_ => Assert.ThrowsAsync<BitweenClientException>(() => stranger.Exchanges.SearchAsync())));
var calls = Enumerable.Range(0, 10)
.Select(_ => Assert.ThrowsAsync<BitweenClientException>(() => stranger.Exchanges.SearchAsync())).ToList();
signIns.Open.SetResult();
var refusals = await Task.WhenAll(calls);
Assert.All(refusals, refused =>
{
Assert.Equal(HttpStatusCode.BadRequest, refused.Status);
Expand All @@ -196,15 +199,22 @@ public async Task Says_what_Bitween_refused_and_why()
Assert.Equal(2, (await neighbour.Exchanges.SearchAsync(new ExchangeQuery { PartnerIds = [partnerId] })).TotalCount);
}

/// <summary>Counts the calls that trade a token for a session.</summary>
/// <summary>Counts the calls that trade a token for a session, and holds them until opened.</summary>
sealed class SignInCounter : DelegatingHandler
{
public int Count;

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellation)
/// <summary>Set once every call that should share a sign-in has been made.</summary>
public readonly TaskCompletionSource Open = new(TaskCreationOptions.RunContinuationsAsynchronously);

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellation)
{
if (request.RequestUri!.AbsolutePath == "/api/accounts/servicetoken") Interlocked.Increment(ref Count);
return base.SendAsync(request, cancellation);
if (request.RequestUri!.AbsolutePath == "/api/accounts/servicetoken")
{
Interlocked.Increment(ref Count);
await Open.Task;
}
return await base.SendAsync(request, cancellation);
}
}
}
4 changes: 2 additions & 2 deletions docs/client-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ A scheduled retry query takes `SubscriptionId`, `InformationTypeId`, `ErrorConta
### Selections

Bulk retry and export take a selection: exchanges by id, or every exchange a query matches, however
many pages that is, with some left out. A query with an empty id list is refused rather than read as no ids at all,
which would match every exchange.
many pages that is, with some left out. A query with an empty list of ids, partners, subscriptions or information types
is refused: an empty list adds no filter, so it would widen the selection rather than narrow it.

```csharp
ExchangeSelection.Of(["a1b2...", "c3d4..."])
Expand Down
5 changes: 3 additions & 2 deletions sdk/node-client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ function exchangeFilters(query = {}) {
function selectionBody(selection) {
if (!selection?.ids === !selection?.matching) throw new TypeError("A selection is { ids } or { matching, except }.");
if (selection.ids) return { ids: [...selection.ids] };
// No ids adds no filter, so it would select every exchange rather than none.
if (selection.matching.ids?.length === 0) throw new TypeError("matching.ids is empty: it would match every exchange.");
// An empty list adds no filter, so it would widen the selection rather than narrow it.
for (const key of ["ids", "partnerIds", "subscriptionIds", "informationTypeIds"])
if (selection.matching[key]?.length === 0) throw new TypeError(`matching.${key} is empty: it would widen the selection, not narrow it.`);
const filters = exchangeFilters(selection.matching);
// An empty filter reads as no filter, and the selection would fall back to its ids, which are
// none. Every attempt, not only the newest, matches everything without narrowing it.
Expand Down
3 changes: 2 additions & 1 deletion sdk/node-client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ test("sends a selection as the bulk endpoints take it", async () => {
await bitween.exchanges.checkExport({ matching: {} });
await assert.rejects(bitween.exchanges.bulkRetry({}), TypeError);
await assert.rejects(bitween.exchanges.bulkRetry({ ids: [], matching: { status: "failed" } }), TypeError);
// No ids would read as no filter, and match every exchange.
// An empty list would read as no filter, and widen the selection.
await assert.rejects(bitween.exchanges.bulkRetry({ matching: { ids: [] } }), TypeError);
await assert.rejects(bitween.exchanges.bulkRetry({ matching: { status: "failed", partnerIds: [] } }), TypeError);

const [byId, matching, everything] = seen.requests.map((r) => [r.path, r.body]);
assert.deepStrictEqual(byId, ["/api/xchanges/bulkretry", { ids: ["a", "b"], reason: "Bulk retry", reset: false }]);
Expand Down
Loading