Our @context stays JSON-LD 1.0, and a 500 no longer takes a host out

The proof term added with integrity proofs had a @graph container, which only JSON-LD 1.1 knows: Smithereen's reader
refused every document carrying our context with a 500. Those 500s, retried, then put Smithereen's host in quarantine
for an hour, and every delivery to it waited. Proofs are canonicalised as JSON (JCS), so the term loses nothing as a
plain id; a test keeps every term within 1.0. Only an unreachable host, a timeout or a gateway's 502, 503 or 504 now
counts against a host: a 500 is the server failing on that one activity, retried on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 13:02:02 +02:00
1 parent b66f0acf4e
commit b478049303
5 files changed
+62 -3

No files matched your search

+2 -1
View File
@@ -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 - **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 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, 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 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. persona follows once more, at most once an hour.
+32
View File
@@ -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<JsonObject>().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<string>()), $"{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<string>());
}
Assert.DoesNotContain(terms.Select(p => p.Key), Keywords11.Contains);
}
}
}
@@ -666,6 +666,27 @@ namespace PrivaPub.Tests.Federation
Assert.StartsWith("500", broken.Error); 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 // 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 // few times; the third refusal is final like any other
[Fact] [Fact]
@@ -258,6 +258,10 @@ namespace PrivaPub.Federation.Outbox
_logger.LogInformation("Delivery to {Inbox} refused with {Status}", payload.Inbox, status); _logger.LogInformation("Delivery to {Inbox} refused with {Status}", payload.Inbox, status);
return JobOutcome.Dead($"{status} {response.ReasonPhrase}"); return JobOutcome.Dead($"{status} {response.ReasonPhrase}");
} }
// 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); await _breaker.Failed(job.Host, $"{status}", token);
return JobOutcome.Retry($"{status} {response.ReasonPhrase}"); return JobOutcome.Retry($"{status} {response.ReasonPhrase}");
} }
@@ -81,13 +81,14 @@ namespace PrivaPub.Federation.Rendering
["wallPostVisibility"] = "sm:wallPostVisibility", ["wallPostVisibility"] = "sm:wallPostVisibility",
["allowedTo"] = "sm:allowedTo", ["allowedTo"] = "sm:allowedTo",
// FEP-521a keys and FEP-8b32 proofs, defined here rather than by the data-integrity and multikey contexts, // 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" }, ["assertionMethod"] = new JsonObject { ["@id"] = "sec:assertionMethod", ["@type"] = "@id", ["@container"] = "@set" },
["Multikey"] = "sec:Multikey", ["Multikey"] = "sec:Multikey",
["controller"] = new JsonObject { ["@id"] = "sec:controller", ["@type"] = "@id" }, ["controller"] = new JsonObject { ["@id"] = "sec:controller", ["@type"] = "@id" },
["publicKeyMultibase"] = new JsonObject { ["@id"] = "sec:publicKeyMultibase", ["@type"] = "sec:multibase" }, ["publicKeyMultibase"] = new JsonObject { ["@id"] = "sec:publicKeyMultibase", ["@type"] = "sec:multibase" },
["DataIntegrityProof"] = "sec:DataIntegrityProof", ["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" }, ["cryptosuite"] = new JsonObject { ["@id"] = "sec:cryptosuite", ["@type"] = "sec:cryptosuiteString" },
["proofValue"] = new JsonObject { ["@id"] = "sec:proofValue", ["@type"] = "sec:multibase" }, ["proofValue"] = new JsonObject { ["@id"] = "sec:proofValue", ["@type"] = "sec:multibase" },
["proofPurpose"] = new JsonObject { ["@id"] = "sec:proofPurpose", ["@type"] = "@vocab" }, ["proofPurpose"] = new JsonObject { ["@id"] = "sec:proofPurpose", ["@type"] = "@vocab" },