diff --git a/FEDERATION.md b/FEDERATION.md index 25e243b..5ceab84 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -391,7 +391,8 @@ Posts with a location (shown to nearby users of this server) never leave the ser - **Delivery.** An activity goes once to each server, to its shared inbox when the server has one, whoever there it follows, names or answers (a circle post excepted: each member's copy names that member). Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused, - starting at an hour and growing to a week. A server can also take a Follow (202) and drop it afterwards, as Pleroma + starting at an hour and growing to a week: unreachable, timing out, or a gateway answering 502, 503 or 504. A 500 is + the server failing on that one activity, retried on its own while its other deliveries go on. A server can also take a Follow (202) and drop it afterwards, as Pleroma does when it cannot yet fetch our actor, so a follow request still unanswered is sent again, same activity, when the persona follows once more, at most once an hour. diff --git a/PrivaPub.Tests/Federation/ContextTests.cs b/PrivaPub.Tests/Federation/ContextTests.cs new file mode 100644 index 0000000..dd17651 --- /dev/null +++ b/PrivaPub.Tests/Federation/ContextTests.cs @@ -0,0 +1,32 @@ +using PrivaPub.Federation.Rendering; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + // The @context on everything we send. Readers that expand JSON-LD 1.0 (Smithereen) refuse a whole document over one + // term only 1.1 knows, so every term stays within 1.0 + public class ContextTests + { + static readonly string[] Containers10 = ["@list", "@set", "@index", "@language"]; + static readonly string[] Keywords11 = ["@version", "@protected", "@propagate", "@import", "@nest", "@prefix", "@context", "@direction", "@json"]; + + [Fact] + public void Every_term_is_json_ld_1_0() + { + var terms = ActivityPubRenderer.Context().OfType().Single(); + + foreach (var (name, definition) in terms) + { + if (definition is not JsonObject term) + continue; + if (term["@container"] is JsonValue container) + Assert.True(Containers10.Contains(container.GetValue()), $"{name}: @container {container} is JSON-LD 1.1"); + Assert.True(term["@container"] is null or JsonValue, $"{name}: an array of containers is JSON-LD 1.1"); + Assert.DoesNotContain(term.Select(p => p.Key), Keywords11.Contains); + Assert.NotEqual("@json", term["@type"]?.GetValue()); + } + Assert.DoesNotContain(terms.Select(p => p.Key), Keywords11.Contains); + } + } +} diff --git a/PrivaPub.Tests/Federation/JobHandlerTests.cs b/PrivaPub.Tests/Federation/JobHandlerTests.cs index 2f1e994..529146c 100644 --- a/PrivaPub.Tests/Federation/JobHandlerTests.cs +++ b/PrivaPub.Tests/Federation/JobHandlerTests.cs @@ -666,6 +666,27 @@ namespace PrivaPub.Tests.Federation Assert.StartsWith("500", broken.Error); } + // a server that fails on one activity is up: its 500s are that delivery's to retry, and only a gateway's 502, 503 or + // 504 counts towards taking the host out (Smithereen's 500s on documents it could not read once held every delivery + // to it back for an hour) + [Fact] + public async Task Only_a_gateways_failures_take_the_host_out() + { + var (_, alice) = await _harness.Persona("alice"); + _harness.Peer.Answer("/broken/inbox", 500); + _harness.Peer.Answer("/gateway/inbox", 502); + + for (var i = 0; i < HostCircuitBreaker.Threshold; i++) + await Attempt(alice, _harness.Peer.A + "/broken/inbox"); + var stillTried = await Attempt(alice, _harness.Peer.A + "/broken/inbox"); + for (var i = 0; i < HostCircuitBreaker.Threshold; i++) + await Attempt(alice, _harness.Peer.A + "/gateway/inbox"); + var heldBack = await Attempt(alice, _harness.Peer.A + "/broken/inbox"); + + Assert.Equal((JobResult.Retry, "500"), (stillTried.Result, stillTried.Error[..3])); + Assert.Equal((JobResult.Defer, "the host is unavailable"), (heldBack.Result, heldBack.Error)); + } + // Mastodon's 422 for two first contacts racing to create one account, and its 409 for a held lock, are retried a // few times; the third refusal is final like any other [Fact] diff --git a/PrivaPub/Federation/Outbox/DeliveryService.cs b/PrivaPub/Federation/Outbox/DeliveryService.cs index d097c81..83cd450 100644 --- a/PrivaPub/Federation/Outbox/DeliveryService.cs +++ b/PrivaPub/Federation/Outbox/DeliveryService.cs @@ -258,7 +258,11 @@ namespace PrivaPub.Federation.Outbox _logger.LogInformation("Delivery to {Inbox} refused with {Status}", payload.Inbox, status); return JobOutcome.Dead($"{status} {response.ReasonPhrase}"); } - await _breaker.Failed(job.Host, $"{status}", token); + // a gateway's 502, 503 or 504 says the server behind it is down, which counts against the host; any other 5xx + // says the server is up and failed on this activity (Smithereen's 500 on a document its JSON-LD reader + // refused), retried on its own while the host's other deliveries go on + if (status is 502 or 503 or 504) + await _breaker.Failed(job.Host, $"{status}", token); return JobOutcome.Retry($"{status} {response.ReasonPhrase}"); } catch (BlockedDestinationException ex) diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index c9b44b5..64e688c 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -81,13 +81,14 @@ namespace PrivaPub.Federation.Rendering ["wallPostVisibility"] = "sm:wallPostVisibility", ["allowedTo"] = "sm:allowedTo", // FEP-521a keys and FEP-8b32 proofs, defined here rather than by the data-integrity and multikey contexts, - // which strict JSON-LD readers would have to fetch + // which strict JSON-LD readers would have to fetch. JSON-LD 1.0 only: Smithereen's reader refuses the whole + // document over a 1.1 term (proof's @graph container there; our proofs are canonicalised as JSON, by JCS) ["assertionMethod"] = new JsonObject { ["@id"] = "sec:assertionMethod", ["@type"] = "@id", ["@container"] = "@set" }, ["Multikey"] = "sec:Multikey", ["controller"] = new JsonObject { ["@id"] = "sec:controller", ["@type"] = "@id" }, ["publicKeyMultibase"] = new JsonObject { ["@id"] = "sec:publicKeyMultibase", ["@type"] = "sec:multibase" }, ["DataIntegrityProof"] = "sec:DataIntegrityProof", - ["proof"] = new JsonObject { ["@id"] = "sec:proof", ["@type"] = "@id", ["@container"] = "@graph" }, + ["proof"] = new JsonObject { ["@id"] = "sec:proof", ["@type"] = "@id" }, ["cryptosuite"] = new JsonObject { ["@id"] = "sec:cryptosuite", ["@type"] = "sec:cryptosuiteString" }, ["proofValue"] = new JsonObject { ["@id"] = "sec:proofValue", ["@type"] = "sec:multibase" }, ["proofPurpose"] = new JsonObject { ["@id"] = "sec:proofPurpose", ["@type"] = "@vocab" },