Skip to content

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, BR-13). If IControllerRetriever is not registered, MVC activation fails before Get runs.*
  2. If guid == null, set guid = Guid.NewGuid().ToString() (BR-02).
  3. Inside a single try block spanning steps 3–6: call Provider.GetServices<IPingableService>() to resolve every service registered through AddPingable* (BR-03, BR-05); see OP-03. 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). 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). 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 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). 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, BR-08):
  6. 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.
  7. If Ping throws and IsThrowablePingError classifies the exception as false, the default implementation swallows the exception without rethrowing and falls through to invoke OnPingSuccess.
  8. If Ping completes without throwing, the default implementation proceeds directly to invoke OnPingSuccess — the same hook reached by the false-classification branch above.
  9. 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.
  10. 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 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 (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, BR-10). 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, BR-06, BR-10); see OP-05. 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); 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). 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).

Process flow

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 IPingableServices 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). 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.

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