---
title: "OP-01: Ping the API"
summary: The GET /Ping endpoint that resolves and pings every target.
status: draft
owner: SAIF Platform Team
last_reviewed: 2026-10-02
library: SAIF.NetCore.PingableService
reference_type: package
operation_id: OP-01
trigger: Anonymous HTTP GET /Ping or GET /Ping?guid=<runId>.
---

# OP-01: Ping the API

| Field | Value |
| --- | --- |
| Operation ID | OP-01 |
| Trigger | `GET /Ping` or `GET /Ping?guid=<runId>`, anonymous (`[AllowAnonymous]`). |
| Inputs | Optional `guid` query parameter; constructor-injected `IServiceProvider` and `IControllerRetriever`; optional `ISaifLogService`. |
| Outputs | `200 OK` with the run-id string as the body, or a thrown `PingServiceException` (status 500). |
| Primary components | `PingableController` |
| Entry point | `PingableController.Get` |

## Steps
1. MVC activates `PingableController` with `IServiceProvider` and `IControllerRetriever`; the constructor resolves `ISaifLogService` with a null-tolerant `GetService` call and assigns it only if non-null ([BR-12](../business-rules.md#br-12-logging-is-optional), [BR-13](../business-rules.md#br-13-withpingablecontroller-enables-route-and-controller-discovery)). If `IControllerRetriever` is not registered, MVC activation fails before `Get` runs.*
2. If `guid == null`, set `guid = Guid.NewGuid().ToString()` ([BR-02](../business-rules.md#br-02-run-id-defaults-to-a-generated-guid)).
3. Inside a single `try` block spanning steps 3–6: call `Provider.GetServices<IPingableService>()` to resolve every service registered through `AddPingable*` ([BR-03](../business-rules.md#br-03-ping-set-defined-by-di-registration-and-mvc-application-parts), [BR-05](../business-rules.md#br-05-resolution-and-construction-failures-count-as-ping-failures)); see [OP-03](op-03-register-pingable-service.md). A DI activation failure here (for example, an unresolved constructor dependency on a registered `IPingableService`) throws synchronously and is caught directly by step 7's exception handling.
4. Discover and resolve pingable controllers via `IControllerRetriever.GetControllers(Provider)` and concatenate them onto the service list ([BR-03](../business-rules.md#br-03-ping-set-defined-by-di-registration-and-mvc-application-parts)). Locally, `GetControllers` calls `Assembly.Load` and `GetTypes()` over every application part snapshotted at registration time (no caching), filters for every type implementing both `IPingableService` and `ControllerBase`, and resolves an instance of each discovered concrete type from DI via `IServiceProvider.GetService(type)`, returning the resolved instances to this step ([BR-14](../business-rules.md#br-14-controller-discovery-requires-controllers-registered-as-services)). A DI or reflection failure during this discovery/resolution work propagates the same way as step 3.
   > **Origin**: The assembly-load/type-filtering/concrete-resolution substeps above are implemented in [OP-04](op-04-enable-route-and-discover-controllers.md) steps 2–4 (`ControllerRetriever.GetControllers`, `SAIF.NetCore.PingableService`), executed locally within this library on behalf of this operation.
5. Project the combined list to `services.Select(x => x.PingAsync(guid, SaifLogService))`. This projection is lazy — the `PingAsync` call on each target only actually executes when the resulting `IEnumerable<Task>` is enumerated in step 6, not at the point the `Select` is written ([BR-04](../business-rules.md#br-04-concurrent-ping-execution-via-task.whenall)). For a target whose `IPingableService.PingAsync` is the library's default interface implementation (not overridden), that call locally runs `Task.Run` to invoke `Ping` and then branches on the outcome ([BR-07](../business-rules.md#br-07-ping-exceptions-are-failures-unless-vetoed), [BR-08](../business-rules.md#br-08-non-throwable-ping-errors-count-as-success)):
   - If `Ping` throws and `IsThrowablePingError` classifies the exception as `true`, the default implementation invokes `OnPingFailure` with the original exception and then reaches a bare `throw;` — the original exception is rethrown unchanged only if `OnPingFailure` itself returns/completes normally; if `OnPingFailure` throws instead, its exception replaces the original and propagates in its place.
   - If `Ping` throws and `IsThrowablePingError` classifies the exception as `false`, the default implementation swallows the exception without rethrowing and falls through to invoke `OnPingSuccess`.
   - If `Ping` completes without throwing, the default implementation proceeds directly to invoke `OnPingSuccess` — the same hook reached by the `false`-classification branch above.
   - If `IsThrowablePingError` itself throws while classifying the exception, or `OnPingFailure` itself throws while handling a `true`-classified failure, that new exception replaces the originally caught exception as the one that propagates out of the `Task.Run` delegate.
   - If `OnPingSuccess` throws — whether reached via the clean-`Ping` path or the `false`-classification path — that exception also propagates out of the `Task.Run` delegate, faulting the resulting task even though no earlier step failed.

   A target that overrides `PingAsync` bypasses this entire default implementation; the default's `Task.Run`/classifier/hook branching above does not apply, and behavior is instead supplied by the overriding implementation. See [OP-02](op-02-default-ping-pipeline.md) steps 1–5 for the full substep sequence underlying the default implementation described above.
   > **Origin**: The default `Task.Run`/`Ping`/classifier/hook-invocation substeps above are implemented in [OP-02](op-02-default-ping-pipeline.md) (`IPingableService.PingAsync` default interface implementation, `SAIF.NetCore.PingableService`), executed locally within this library on behalf of this operation.
6. `await RunAllPings(guid, tasks)`:
   1. `RunAllPings` calls `Task.WhenAll(tasks)`. Enumerating `tasks` here is what actually invokes each target's `PingAsync`; if a target throws synchronously before returning a `Task` (rather than returning a task that later faults), that exception propagates out of `Task.WhenAll(tasks)` itself, bypassing `RunAllPings`'s own `try`/`catch`, and is caught by `Get`'s step 7 handling instead.
   2. `await whenAll` can throw for two distinct reasons, and `RunAllPings`'s `catch` block distinguishes them by reading `whenAll.Exception`:
      - **One or more awaited tasks faulted.** `whenAll.Exception` is non-null; `RunAllPings` reads it as an `AggregateException` and throws `new PingServiceException(aex.Message, aex.Flatten(), guid)` — this is the only place `RunAllPings` itself explicitly flattens a concurrent-failure aggregate via `aex.Flatten()` into a `PingServiceException`'s inner exception ([BR-04](../business-rules.md#br-04-concurrent-ping-execution-via-task.whenall), [BR-10](../business-rules.md#br-10-ping-failure-returns-500-with-run-id-and-message)). It is **not** the only possible source of a `PingServiceException` whose inner exception happens to be an `AggregateException` — see the synchronous-failure case below, where a target that throws an `AggregateException` directly (before returning a `Task`) is wrapped intact by `Get`'s own `catch (Exception ex)` without passing through this flattening step at all.
      - **One or more awaited tasks were canceled (not faulted).** `whenAll.Exception` is `null`; `RunAllPings`'s `catch` block falls through to its bare `throw;`, rethrowing the original cancellation exception (for example `TaskCanceledException`) unwrapped — this is **not** aggregated into a `PingServiceException` by `RunAllPings` itself.
7. Back in `Get`, `catch (PingServiceException p) { throw; }` rethrows unchanged only the `PingServiceException` produced by step 6.2's faulted-aggregation branch (or one thrown synchronously and already of that type); it is not re-wrapped a second time. Any other exception type — a DI/reflection failure from steps 3–4, a synchronous throw from step 6.1, or the unwrapped cancellation exception from step 6.2's cancellation branch — is caught by the subsequent `catch (Exception ex)` and wrapped individually as `new PingServiceException(ex.Message, ex, guid)` ([BR-05](../business-rules.md#br-05-resolution-and-construction-failures-count-as-ping-failures), [BR-06](../business-rules.md#br-06-pingserviceexception-is-not-wrapped-twice), [BR-10](../business-rules.md#br-10-ping-failure-returns-500-with-run-id-and-message)); see [OP-05](op-05-raise-ping-failure.md).
   1. Converting the thrown `PingServiceException` into an HTTP response is host-dependent. In hosts built on `BaseApiStartup`, `app.ConfigureErrorHandling()` adds `DynamicErrorHandler` to the pipeline. `DynamicErrorHandler.InvokeAsync` catches the `HttpStatusCodeException` and calls its own `LogAndReturnException`, which:
      - invokes every registered `IErrorHandlerHook` in turn, collecting any hook exception rather than letting it abort the response;
      - if one or more hooks failed, builds a new `AggregateException` from those hook failures plus the original `PingServiceException` and passes that aggregate — instead of the original exception — to the injected `IErrorHandler`; otherwise it passes the original exception through unchanged;
      - delegates to the injected `IErrorHandler` (the `ErrorHandler` class)'s `LogAndReturnException`, which first calls `IsTooHotToHandle` to check `context.Response.HasStarted` — if the response has already started, it logs a Warning and re-throws the exception immediately, writing **no** body and skipping the remaining steps; otherwise it logs the error at Error level and captures the returned `ErrorID` *before* touching the response, then clears the response, sets the `Error-ID`/`Server-Name` headers and status/content type, and finally writes the body via `BuildHandledResponseMessage`, which branches on the response `contentType`: when it contains `"text/plain"` (`PingServiceException`'s inherited default) the body is `"{ex.Message}\nPlease see error: {ErrorID}."`; for any other content type the body is `ex.Message` alone, with no appended error-ID line.
      > **Origin**: The hook-invocation/failure-aggregation behavior above is implemented in [Dynamic error handler invocation](../../saif-netcore-serviceextensions/operations/dynamic-error-handler-invoke.md) (`SAIF.NetCore.ServiceExtensions`); the already-started-response rejection and log-before-response sequence are implemented in [Log an exception and write the HTTP error response](../../saif-netcore-errorhandler/operations/log-and-return-exception.md) (`SAIF.NetCore.ErrorHandler`). Both are executed on behalf of this operation.
8. On success (no exception reached step 7), return `Ok(guid)` — `200 OK` with the run id as the body ([BR-09](../business-rules.md#br-09-successful-run-returns-200-with-run-id)).

## Process flow

```mermaid
flowchart TD
    A[GET /Ping received] --> B["Construct controller, resolve optional ISaifLogService"]
    B --> C{guid provided?}
    C -->|No| C1[Generate new GUID]
    C -->|Yes| D[Use provided guid]
    C1 --> E["Resolve DI-registered IPingableService targets"]
    D --> E
    E -->|"DI/reflection failure"| X1["Thrown synchronously -<br>caught by outer catch"]
    E --> F["Discover + resolve pingable controllers"]
    F -->|"DI/reflection failure"| X1
    F --> G["Build lazy PingAsync projection"]
    G --> H["Task.WhenAll(tasks) enumerates projection -<br>invokes each PingAsync"]
    H -->|"synchronous throw before Task returned"| X1
    H --> I["await whenAll"]
    I --> J{"await whenAll threw?"}
    J -->|No| K[Return 200 OK with guid]
    J -->|"Yes, whenAll.Exception != null<br>(faulted)"| L["RunAllPings wraps aex.Flatten()<br>into new PingServiceException"]
    J -->|"Yes, whenAll.Exception == null<br>(canceled)"| L2["RunAllPings rethrows the<br>cancellation exception unwrapped"]
    L --> M{"Caught by Get as<br>PingServiceException?"}
    X1 --> N{"Already a PingServiceException?"}
    L2 --> N
    M -->|Yes| O["catch (PingServiceException p) { throw; } -<br>rethrown unchanged"]
    N -->|Yes| O
    N -->|No| P["catch (Exception ex) -<br>wrapped individually, new PingServiceException(ex.Message, ex, guid)"]
    O --> Q["DynamicErrorHandler: run hooks, aggregate failures"]
    P --> Q
    Q --> R{"Response already started?"}
    R -->|Yes| S["ErrorHandler logs Warning,<br>re-throws, no body written"]
    R -->|No| T["ErrorHandler logs Error, captures ErrorID,<br>writes headers + body"]
```

## Failure and edge-case behavior
- Three distinct failure paths exist and must not be conflated:
  - **Faulted-task aggregation**: when one or more of the *awaited* `PingAsync` tasks completes Faulted, `RunAllPings` catches the `await whenAll` fault, reads `whenAll.Exception` as an `AggregateException`, and throws `new PingServiceException(aex.Message, aex.Flatten(), guid)`. This is the only path where `RunAllPings` itself explicitly flattens concurrently-faulted targets into the `AggregateException` inner exception via `aex.Flatten()` — it is not the only path that can leave a `PingServiceException` with an `AggregateException` inner exception; see Synchronous failures below.
  - **Cancellation**: when one or more of the awaited tasks is canceled rather than faulted, `whenAll.Exception` is `null`, and `RunAllPings`'s `catch` block falls through to a bare `throw;`, rethrowing the cancellation exception unwrapped. This is **not** a `PingServiceException` until `Get`'s own `catch (Exception ex)` wraps it.
  - **Synchronous failures**: a DI/reflection failure while resolving `IPingableService`s or pingable controllers (steps 3–4), or a target throwing synchronously before returning a `Task` during `Task.WhenAll(tasks)`'s enumeration of the projection (step 6.1), bypasses `RunAllPings`'s `await`-scoped `catch` entirely and is caught directly by `Get`'s own `try`/`catch`. A synchronous `AggregateException` thrown by a target before returning a `Task` is caught the same way — passed intact to `Get`'s `catch (Exception ex)` and wrapped as `new PingServiceException(ex.Message, ex, guid)` — rather than flattened by `RunAllPings`.
- "An existing `PingServiceException` is never double-wrapped" is true **only** for the `PingServiceException` that step 6.2's faulted-aggregation branch (or an already-typed synchronous failure) produces and that reaches `Get`'s `catch (PingServiceException p) { throw; }` block directly ([BR-06](../business-rules.md#br-06-pingserviceexception-is-not-wrapped-twice)). It does **not** mean a `PingServiceException` thrown by a nested `IPingableService`/pingable-controller implementation is preserved standalone when it faults as part of the awaited batch: in that case `Task.WhenAll` aggregates it into an `AggregateException`, and `RunAllPings` wraps that aggregate into a *new* outer `PingServiceException` whose `Flatten()`-ed inner exception includes the original.
- Without `DynamicErrorHandler` (or an equivalent `IErrorHandler` pipeline) registered, ASP.NET Core's default unhandled-exception handling still produces a 500, but without the SAIF-formatted body, `Error-ID` header, or `Server-Name` header.
- A hook registered with `DynamicErrorHandler` that itself throws does not suppress the original `PingServiceException` — both are aggregated and passed to `ErrorHandler` together — and a response that has already started by the time `ErrorHandler.LogAndReturnException` runs skips the Error log entry and body write entirely, logging only a Warning and re-throwing instead.
- The host-visible response body for a handled exception depends on the response `contentType`: for the `"text/plain"` default it is `ex.Message` plus an appended error-ID line; for any other content type it is `ex.Message` alone, with no error-ID line appended — see [PingServiceException](../api/SAIF.NetCore.Services.PingableService.Exceptions/PingServiceException.md#usage).

## Related documentation
- [Operations and processes](../operations-processes.md)
- [Business rules](../business-rules.md)
- [Service registration](../service-registration.md)
- [Dynamic error handler invocation](../../saif-netcore-serviceextensions/operations/dynamic-error-handler-invoke.md) (`SAIF.NetCore.ServiceExtensions`)
- [Log an exception and write the HTTP error response](../../saif-netcore-errorhandler/operations/log-and-return-exception.md) (`SAIF.NetCore.ErrorHandler`)

> **Note:** Statements marked with `*` were inferred from code structure, naming
> conventions, or contextual evidence where direct confirmation was not available
> in source artifacts.

## Sources
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs`
- `src/SAIF.NetCore.ServiceExtensions/SAIF.NetCore.ServiceExtensions/SAIF.NetCore.ServiceExtensions/Middleware/DynamicErrorHandler.cs`
- `src/SAIF.NetCore.ServiceExtensions/SAIF.NetCore.ServiceExtensions/SAIF.NetCore.ServiceExtensions/Startup/BaseApiStartup.cs`
- `src/SAIF.NetCore.ErrorHandler/SAIF.NetCore.ErrorHandler/SAIF.NetCore.ErrorHandler/ErrorHandler.cs`
