Tri-state per-source envelopes so failed dependencies never masquerade as empty results
A narrow procedure for fan-out aggregation: wrap every dependency result in an explicit tri-state envelope of data, empty, or unavailable, compute the aggregate only from the first two, and surface coverage so callers can tell a real zero from a missing source. The envelope covers the whole source fetch, so a partially paged or truncated answer is unavailable, not data.
Tri-state per-source envelopes for fan-out aggregation
Problem
When a request fans out to several dependencies and one of them fails, a common shortcut is to substitute an empty list or zero for the failed source and continue. The aggregate then looks complete while silently omitting a slice of the data. Downstream consumers cannot distinguish "this source truly had nothing" from "this source never answered". The damage compounds when the misleading aggregate is cached, written to a report, or used to make a decision such as deleting stale records.
The opposite shortcut, failing the whole request when any single source fails, throws away the results that did succeed and makes the system only as reliable as its weakest dependency.
This skill describes one narrow, mechanical rule that avoids both failure modes.
The rule
Every per-source result must be one of exactly three states before it enters aggregation:
- DATA: the source responded successfully and returned one or more items.
- EMPTY: the source responded successfully and affirmatively returned zero items.
- UNAVAILABLE: the source did not produce a trustworthy answer. This covers timeouts, connection errors, non-success status codes, malformed payloads, and any exception thrown by the client.
The key discipline is that EMPTY may only be produced by an actual successful response. A catch block, a timeout handler, or a default value must never yield EMPTY. It must yield UNAVAILABLE with a reason.
The unit that receives a state is the whole answer from one source, not one network call. When a source answer is assembled from several round trips, such as a paged listing, a cursor walk, or a stream, the envelope describes the assembled answer. DATA means every part arrived. A partly assembled answer is UNAVAILABLE.
Procedure
Step 1. Define the envelope type once. It carries a status field with the three values above, an optional items field that is only present for DATA and EMPTY, and an optional reason field that is only present for UNAVAILABLE. Do not use a nullable list to represent this; null tends to be coerced to empty by serializers and default arguments.
Step 2. Wrap each dependency call at the boundary. The wrapper converts the raw call into an envelope. Success with items becomes DATA. Success with a zero-length body becomes EMPTY. Anything else becomes UNAVAILABLE and records a short, non-sensitive reason category such as timeout, httperror, parseerror, or exception. Keep the reason a category, not the raw error message, so it is safe to expose in responses. If the source requires several round trips, the wrapper owns the whole loop and emits one envelope for the assembled result. It must not emit DATA until the source has signalled that there are no more parts, for example by returning no continuation token or by sending its declared end marker.
Step 3. Aggregate only over DATA and EMPTY envelopes. UNAVAILABLE envelopes contribute nothing to the merged items. Never coerce them to an empty list before merging.
Step 4. Attach coverage metadata to the aggregate. At minimum expose the count of sources attempted, the count that answered, and a list of the source names that were UNAVAILABLE with their reason categories. A single boolean such as "complete" is acceptable as a convenience but should sit alongside the detail, not replace it.
Step 5. Gate side effects on coverage. Any downstream action whose correctness depends on completeness, such as cache writes with a long lifetime, reconciliation deletes, or "nothing found" notifications, must check that no source was UNAVAILABLE before proceeding. Actions that are safe on partial data, such as rendering the items that did arrive, may proceed immediately.
Step 6. Make the caller decide, not the aggregator. The aggregator should never decide that a partial result is "good enough". It returns partial data plus coverage and lets the caller apply its own threshold.
Deciding what counts as EMPTY
The boundary between EMPTY and UNAVAILABLE is where most bugs live. Use these tests:
- A 200 response with an empty array is EMPTY.
- A 404 for a collection endpoint is ambiguous. Treat it as UNAVAILABLE unless the dependency documents that 404 means "no items for this key". Document your decision at the wrapper.
- A 200 response whose body fails to parse is UNAVAILABLE, not EMPTY. Do not let a JSON error fall through to a default empty value.
- A response that arrives after the deadline is UNAVAILABLE even if it eventually contained items. Late data cannot be trusted to have been considered.
- A circuit breaker in the open state is UNAVAILABLE with reason circuit_open. It is not EMPTY.
Deciding what counts as DATA when an answer spans several round trips
The boundary between DATA and UNAVAILABLE has its own trap: an answer that is partly fetched. Use these tests:
- A paged listing where every page succeeded and the last page carried no continuation token is DATA if any page had items, and EMPTY if every page had zero items.
- A paged listing where any page after the first failed, timed out, or arrived after the whole-source deadline is UNAVAILABLE with reason partial_fetch. Items from the pages that did succeed are not passed through as DATA. A partial slice looks exactly like a complete slice to everything downstream, so it must not enter aggregation.
- A success status whose body carries an items section next to a non-empty errors section, as some query protocols allow, is UNAVAILABLE with reason partial_body. Do not read the items and ignore the errors.
- A streamed response that closes before its declared end marker, or that ends with a transport error after some records were read, is UNAVAILABLE with reason truncated_stream.
- The deadline that matters is the deadline for the whole source fetch, not a per-page timeout. A slow fetch whose pages all complete inside the whole-source deadline is DATA. Keying the rule on per-page timeouts would misclassify healthy but slow sources.
The three states remain sufficient. Partial fetch is a reason category on UNAVAILABLE, not a fourth state, because a caller has nothing completeness-safe it can do with a partial slice that it could not also do by treating the source as absent.
Reasoned examples
These are reasoned walkthroughs, not executed tests.
Example A. A search page queries three indexes. Index two times out. Under the old approach the page shows results from indexes one and three with no indication anything is missing. Under this rule the response carries the merged items plus coverage stating two of three answered and index two was UNAVAILABLE due to timeout. The page renders the items and a small notice that some results may be missing. The result cache stores the response with a short lifetime instead of the normal long one because coverage is incomplete.
Example B. A nightly job fetches the current member list from an identity provider and deletes local accounts not present in it. The provider returns a 503. Under the old approach the catch block returns an empty list and every local account is deleted. Under this rule the envelope is UNAVAILABLE, the aggregate has zero answering sources, and the delete step refuses to run because coverage is incomplete. This is precisely the class of incident the rule exists to prevent.
Example C. A dashboard sums order totals across four regional services. One region genuinely has no orders today and returns an empty array with status 200. That is EMPTY. It participates in aggregation, contributes zero, and coverage reports four of four answered. The dashboard correctly shows a complete total.
Example D. The same nightly job as Example B, but the identity provider is healthy and returns members fifty per page with a continuation token. There are one hundred and twenty members, so three pages. Page one returns fifty members and a token. The request for page two times out. A wrapper that classifies per call sees a successful first response with fifty items and emits DATA. Coverage reports one of one answered, the completeness gate opens, and the job deletes the seventy local accounts that were on pages two and three. That is the Example B incident reached through a path the per-call rule does not catch. Under the whole-answer rule the wrapper owns the paging loop, the page two timeout makes the assembled answer UNAVAILABLE with reason partial_fetch, the fifty fetched members are discarded, and the delete step refuses to run. If instead all three pages return and the third carries no token, the envelope is DATA with one hundred and twenty members and the delete step may proceed. If the provider returns three pages that all contain zero members, the envelope is EMPTY, coverage is complete, and the job correctly treats the tenant as having no members.
Testing checklist
Write one test per row. Each row states the stimulus and the expected envelope state and aggregate behavior.
- Success with items yields DATA and items appear in aggregate.
- Success with zero items yields EMPTY and coverage counts the source as answered.
- Timeout yields UNAVAILABLE with reason timeout and the source is excluded from the answered count.
- Non-success status yields UNAVAILABLE and the aggregate still includes items from other sources.
- Malformed body yields UNAVAILABLE, not EMPTY.
- A paged fetch whose second page fails yields UNAVAILABLE with reason partial_fetch, and no items from the first page reach the aggregate.
- A paged fetch whose every page succeeds and whose last page has no continuation token yields DATA with the full item set.
- A paged fetch whose every page succeeds with zero items yields EMPTY, not UNAVAILABLE.
- A success body with items and a non-empty errors section yields UNAVAILABLE with reason partial_body.
- A completeness-gated side effect does not run when any source is UNAVAILABLE.
- A completeness-gated side effect does run when all sources are DATA or EMPTY, including the all-EMPTY case.
The partialfetch row and the last two rows are the ones most often missing. The all-EMPTY case is important because it proves that a real zero is not being mistaken for a failure. The partialfetch row is important because it proves that a real failure hidden behind a successful first page is not being mistaken for data.
Anti-patterns to remove during review
- A catch block that returns an empty collection.
- A default parameter value of an empty collection on a function that performs a network call.
- Aggregators that accept a plain list of lists with no per-source status.
- A single boolean "partial" flag with no record of which source was missing or why.
- Caching the aggregate with the same lifetime regardless of coverage.
- A paging loop that breaks out on error and returns the items accumulated so far as if the fetch had completed.
- A per-call wrapper placed inside a paging loop, so that each page gets its own envelope and the loop decides completeness by itself.
Scope
This skill is intentionally narrow. It does not cover retry policy, circuit breaker tuning, or how to rank partial results. It covers only the representation of per-source outcomes and the gating of completeness-dependent actions on that representation. It also does not define a display-only channel for the items of a partially fetched source. A future revision could add an optional field on an UNAVAILABLE envelope for rendering purposes, but aggregation and gating would still have to ignore that field, and this revision leaves it out.