Skip to content

8.8 - Global Exception Handling ​

Tóm tắt: Không có xử lý lỗi tập trung, mỗi controller tự try/catch theo một kiểu, và client nhận về mỗi chỗ một định dạng lỗi khác nhau. IExceptionHandler (.NET 8+) gom việc đó về một nơi: khai báo một lần rằng NotFoundException là 404 và ValidationException là 422, rồi mọi action đều đúng. Hai nguyên tắc không thương lượng: log đầy đủ ở server, trả tối thiểu cho client — stack trace tiết lộ cấu trúc thư mục, tên thư viện và phiên bản; và UseExceptionHandler() phải đứng đầu pipeline, vì nó chỉ bắt được exception từ những lớp bên trong nó.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Cài IExceptionHandler và xếp chuỗi nhiều handler theo thứ tự.
  • Thiết kế cây exception nghiệp vụ ánh xạ sang mã HTTP.
  • Phân biệt mức log phù hợp cho từng loại exception.
  • Giải thích những gì không bao giờ được lọt vào phản hồi lỗi.
  • Biết loại exception nào handler không bắt được.

Nội dung bài học ​

8.8.1 — IExceptionHandler ​

csharp
public sealed class GlobalExceptionHandler(
    ILogger<GlobalExceptionHandler> logger,
    IHostEnvironment env,
    IProblemDetailsService problemDetailsService) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken ct)
    {
        var (status, title) = exception switch
        {
            NotFoundException            => (StatusCodes.Status404NotFound, "Không tìm thấy"),
            DomainValidationException    => (StatusCodes.Status422UnprocessableEntity, "Dữ liệu không hợp lệ"),
            ConflictException            => (StatusCodes.Status409Conflict, "Xung đột trạng thái"),
            UnauthorizedAccessException  => (StatusCodes.Status403Forbidden, "Không đủ quyền"),
            _                            => (StatusCodes.Status500InternalServerError, "Lỗi hệ thống")
        };

        // Lỗi của hệ thống mới là sự cố; lỗi nghiệp vụ chỉ là cảnh báo
        if (status >= 500)
            logger.LogError(exception, "Lỗi chưa xử lý tại {Path}", context.Request.Path);
        else
            logger.LogWarning(exception, "Lỗi nghiệp vụ tại {Path}", context.Request.Path);

        context.Response.StatusCode = status;

        return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Status = status,
                Title = title,
                // CHI lo chi tiet ngoai Production
                Detail = env.IsProduction() ? null : exception.Message,
                Instance = context.Request.Path,
                Extensions = { ["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier }
            }
        });
    }
}
csharp
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

var app = builder.Build();
app.UseExceptionHandler();       // DAU pipeline

Ba chi tiết đáng chú ý:

  • IProblemDetailsService áp CustomizeProblemDetails bạn cấu hình ở bài 8.5, nên định dạng lỗi thống nhất với lỗi validation.
  • Mức log phân theo mã. 500 là sự cố của bạn; 404 và 422 là hành vi bình thường của hệ thống. Log tất cả ở Error thì cảnh báo mất ý nghĩa.
  • traceId nối phản hồi lỗi với log. Người dùng báo lỗi kèm traceId là bạn tìm ra log trong vài giây.

8.8.2 — Chuỗi nhiều handler ​

csharp
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();  // chạy trước
builder.Services.AddExceptionHandler<DatabaseExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();      // bắt tất cả

Chúng chạy theo thứ tự đăng ký, và dừng ở handler đầu tiên trả true. Trả false nghĩa là "không phải việc của tôi, chuyển tiếp".

csharp
public sealed class ValidationExceptionHandler(IProblemDetailsService pds) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not DomainValidationException v) return false;   // nhường cho handler sau

        ctx.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;

        return await pds.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = ctx,
            ProblemDetails = new ValidationProblemDetails(v.Errors)
            {
                Status = StatusCodes.Status422UnprocessableEntity,
                Title = "Dữ liệu không hợp lệ"
            }
        });
    }
}

Handler bắt-tất-cả phải đăng ký cuối cùng. Nếu không handler nào trả true, exception tiếp tục lan lên và ASP.NET Core trả 500 mặc định.

8.8.3 — Cây exception nghiệp vụ ​

csharp
public abstract class DomainException(string message) : Exception(message);

public sealed class NotFoundException(string entity, object id)
    : DomainException($"Không tìm thấy {entity} với id '{id}'")
{
    public string Entity { get; } = entity;
    public object Id { get; } = id;
}

public sealed class ConflictException(string message) : DomainException(message);

public sealed class DomainValidationException(IDictionary<string, string[]> errors)
    : DomainException("Dữ liệu không hợp lệ")
{
    public IReadOnlyDictionary<string, string[]> Errors { get; } = errors.AsReadOnly();
}

Lớp cơ sở DomainException cho phép phân biệt lỗi dự kiến được với lỗi hạ tầng bằng một dòng:

csharp
var status = exception switch
{
    NotFoundException         => 404,
    ConflictException         => 409,
    DomainValidationException => 422,
    DomainException           => 400,   // moi loi nghiep vu khac
    _                         => 500    // ha tang: SQL, mang, bug
};

Nhưng đừng dùng exception cho luồng thông thường. Ném exception tốn kém hơn trả về một kết quả khoảng vài trăm lần, và với thứ xảy ra thường xuyên như "không tìm thấy", điều đó có thể nhìn thấy được trên biểu đồ.

csharp
// Tot hon cho truong hop binh thuong
var customer = await service.GetByIdAsync(id, ct);
return customer is null ? NotFound() : Ok(customer);

Exception dành cho những gì thật sự bất thường: vi phạm ràng buộc sâu trong tầng domain, trạng thái không thể xảy ra, lỗi hạ tầng.

8.8.4 — Không bao giờ lọt ra ngoài ​

csharp
// SAI — lo cau truc noi bo
return Problem(detail: exception.ToString());

Stack trace tiết lộ đường dẫn file nguồn trên máy build, namespace và cấu trúc dự án, tên cùng phiên bản thư viện (kèm CVE đã biết), và đôi khi cả giá trị tham số trong thông điệp exception.

Thông điệp exception cũng nguy hiểm không kém:

SqlException: Login failed for user 'crm_app'. Server 'prod-sql-01.internal'

Một dòng đó cho kẻ tấn công tên user database, tên máy chủ và biết rằng bạn dùng SQL Server.

Nguyên tắc: client nhận title, status, traceId. Còn lại nằm trong log.

Trên Development thì trả exception.Message cho tiện — đó là lý do có env.IsProduction() ở trên. Đừng nhầm nó với UseDeveloperExceptionPage(), thứ tuyệt đối không được bật ở Production.

8.8.5 — Những gì handler không bắt được ​

UseExceptionHandler() chỉ bắt exception từ các lớp bên trong nó. Bốn trường hợp nằm ngoài:

  1. Exception trong middleware đăng ký trước nó. Vì vậy nó phải đứng đầu — sau UseForwardedHeaders() là hợp lý nhất.
  2. Exception sau khi response đã bắt đầu. Không đổi được status code nữa (bài 8.2); ASP.NET Core chỉ có thể ngắt kết nối. Đây là lý do stream một IAsyncEnumerable lớn có rủi ro (bài 6.6).
  3. Exception trong BackgroundService. Nó không đi qua pipeline HTTP. Phải tự try/catch quanh từng công việc (bài 6.8).
  4. async void. Exception đi thẳng lên và làm sập tiến trình (bài 6.2).

Với trường hợp 3 và 4, thêm lưới an toàn ở Program.cs:

csharp
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
    logger.LogCritical(e.ExceptionObject as Exception, "Unhandled exception, tien trinh sap dung");

TaskScheduler.UnobservedTaskException += (_, e) =>
{
    logger.LogError(e.Exception, "Unobserved task exception");
    e.SetObserved();
};

8.8.6 — Rà lại code của bạn ​

Danh sách rà soát xử lý lỗi

  • UseExceptionHandler() đứng đầu pipeline, chỉ sau UseForwardedHeaders().
  • Không còn try/catch lặp lại trong controller cho lỗi đã có handler.
  • Handler bắt-tất-cả được đăng ký cuối cùng.
  • Lỗi nghiệp vụ log mức Warning, lỗi hệ thống log mức Error.
  • Phản hồi lỗi ở Production không chứa stack trace hay message của exception.
  • Mọi phản hồi lỗi có traceId nối được với log.
  • Không dùng exception cho luồng thông thường như không tìm thấy.
  • BackgroundService có try/catch riêng cho từng công việc.
  • Program.cs có handler cho UnhandledException và UnobservedTaskException.

Bài tập áp dụng ​

Bài 1 — Thứ tự đường ống quyết định handler có chạy hay không ​

Đặt UseExceptionHandler() sau một middleware ném ngoại lệ, xác nhận client nhận trang lỗi mặc định thay vì ProblemDetails. Chuyển lên đầu và kiểm chứng lại.

Tiêu chí hoàn thành: bạn giải thích được bằng mô hình đường ống hai chiều ở bài 8.2.

Gợi ý và lời giải — Bài 1

Gợi ý. Ngoại lệ lan ngược ra ngoài theo ngăn xếp gọi. Chỉ middleware bọc ngoài chỗ ném mới có cơ hội bắt.

Lời giải — bản sai:

csharp
var app = builder.Build();

app.Use(async (ctx, next) =>
{
    if (ctx.Request.Path == "/loi") throw new InvalidOperationException("nổ ở đây");
    await next();
});

app.UseExceptionHandler();      // quá muộn — nằm BÊN TRONG middleware trên
app.MapControllers();
bash
curl -i localhost:5000/loi

Ở Development, bạn nhận trang lỗi chi tiết của developer. Ở Production, một phản hồi 500 trống rỗng — không ProblemDetails, không mã tương quan.

Bản đúng:

csharp
app.UseExceptionHandler();      // ĐẦU TIÊN — bọc ngoài mọi thứ

app.Use(async (ctx, next) => { /* ... */ });
app.MapControllers();
json
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "Đã xảy ra lỗi khi xử lý yêu cầu",
  "status": 500,
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

Giải thích bằng mô hình đường ống. Nhớ lại cấu trúc lồng nhau:

text
Sai:   [middleware ném] ( [UseExceptionHandler] ( [MapControllers] ) )
       ngoại lệ ném ở LỚP NGOÀI -> không có ai bọc nó -> lọt ra ngoài

Đúng:  [UseExceptionHandler] ( [middleware ném] ( [MapControllers] ) )
       ngoại lệ ném ở lớp trong -> lan ra và bị lớp ngoài bắt

Cùng một nguyên tắc với khối try/catch: catch chỉ bắt được những gì ném bên trong khối try tương ứng.

Thứ tự chuẩn — UseExceptionHandler luôn là dòng đầu tiên:

csharp
var app = builder.Build();

app.UseExceptionHandler();         // 1. bọc ngoài cùng
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Ba loại ngoại lệ mà UseExceptionHandler không bắt được:

LoạiVì saoXử lý bằng
Ném trong chính exception handlerKhông có lớp nào bọc nóGiữ handler cực đơn giản
Ném sau khi phản hồi đã bắt đầuKhông sửa được phản hồi đang gửiKiểm tra Response.HasStarted
Ném lúc khởi động, trước app.Run()Đường ống chưa tồn tạitry/catch quanh Program.Main

Loại thứ hai đáng chú ý: nếu một endpoint đã ghi một phần body rồi mới ném, ProblemDetails không thể được gửi vì header đã đi — đúng vấn đề HasStarted ở bài 8.2. Client nhận một phản hồi cụt, và đây là một trong những lỗi khó chẩn đoán nhất.

Bắt lỗi khởi động:

csharp
try
{
    var app = builder.Build();
    // ...
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Ứng dụng không khởi động được");
    throw;
}
finally
{
    Log.CloseAndFlush();      // quan trọng — đẩy hết log ra trước khi thoát
}

Không có try/catch này, một lỗi khởi động chỉ để lại dấu vết trong output của tiến trình — thứ có thể đã biến mất khi container bị xoá.

Bài 2 — Rò rỉ thông tin qua thông báo lỗi ​

Cấu hình handler trả exception.ToString(), gây một lỗi kết nối database bằng mật khẩu sai, và ghi lại chính xác những thông tin lọt ra ngoài.

Tiêu chí hoàn thành: bạn liệt kê được ít nhất năm loại thông tin nội bộ bị lộ.

Gợi ý và lời giải — Bài 2

Gợi ý. Đừng chỉ nhìn thông báo lỗi. Hãy đọc toàn bộ chuỗi mà ToString() trả về, gồm cả dấu vết ngăn xếp và ngoại lệ bên trong.

Lời giải — bản rò rỉ:

csharp
app.UseExceptionHandler(a => a.Run(async ctx =>
{
    var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
    ctx.Response.StatusCode = 500;
    await ctx.Response.WriteAsync(ex?.ToString() ?? "");   // RÒ RỈ
}));

Phản hồi khi sai mật khẩu database:

text
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed
for user "crm_prod_user"
   at Npgsql.Internal.NpgsqlConnector.<Authenticate>g__AuthenticateSASL|...
   at Crm.Infrastructure.EfOrderRepository.LayTheoIdAsync(Int32 id, CancellationToken ct)
      in /home/deploy/src/Crm.Infrastructure/EfOrderRepository.cs:line 47
   at Crm.Application.OrderService.XuLyAsync(...)
      in /home/deploy/src/Crm.Application/OrderService.cs:line 112
   at Crm.Api.Controllers.OrdersController.Tao(...)
      in /home/deploy/src/Crm.Api/Controllers/OrdersController.cs:line 38
  Severity: FATAL, SqlState: 28P01, Routine: auth_failed, File: auth.c, Line: 326

Bảy loại thông tin bị lộ:

#Thông tinKẻ tấn công dùng để
1Loại database: PostgreSQL, và phiên bản driverChọn đúng bộ khai thác
2Tên tài khoản database: crm_prod_userNửa đầu của cặp thông tin đăng nhập
3Đường dẫn máy chủ: /home/deploy/src/...Suy ra cấu trúc triển khai, người dùng hệ thống
4Cấu trúc namespace và tên lớpVẽ bản đồ kiến trúc nội bộ
5Số dòng mã nguồnXác nhận phiên bản, đối chiếu với mã nguồn công khai nếu có
6Mã lỗi nội bộ của PostgreSQL và tên file nguồn của nóXác định chính xác phiên bản database
7Toàn bộ chuỗi gọi từ controller xuống repositoryHiểu luồng xử lý để tìm điểm yếu

Không thông tin nào trong bảng trên có ích cho người dùng hợp lệ. Tất cả đều có ích cho người đang dò tìm.

Bản đúng — tách hai luồng thông tin:

csharp
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> log)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx, Exception ex, CancellationToken ct)
    {
        var traceId = Activity.Current?.Id ?? ctx.TraceIdentifier;

        // ĐẦY ĐỦ vào log phía server
        log.LogError(ex, "Lỗi chưa xử lý. TraceId={TraceId}, Path={Path}",
                     traceId, ctx.Request.Path);

        // CHUNG CHUNG cho client
        var pd = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "Đã xảy ra lỗi khi xử lý yêu cầu",
            Detail = "Vui lòng thử lại. Nếu vẫn lỗi, liên hệ hỗ trợ kèm mã tra cứu.",
            Extensions = { ["traceId"] = traceId }
        };

        ctx.Response.StatusCode = pd.Status.Value;
        await ctx.Response.WriteAsJsonAsync(pd, ct);
        return true;
    }
}

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

Nguyên tắc: log đầy đủ, trả về tối thiểu, nối hai bên bằng mã tra cứu. Người dùng báo lỗi kèm traceId, bộ phận hỗ trợ tra ra đúng chuỗi log trong vài giây — mà không tiết lộ gì ra ngoài.

Ngoại lệ nghiệp vụ thì khác. Chúng được thiết kế để người dùng đọc, nên trả chi tiết là đúng:

csharp
ex switch
{
    NotFoundException e     => (404, e.Message),
    ValidationException e   => (400, e.Message),
    DuplicateEmailException => (409, "Email đã được sử dụng"),
    _                       => (500, "Đã xảy ra lỗi khi xử lý yêu cầu")   // mặc định: giấu
}

Điểm mấu chốt: danh sách cho phép, không phải danh sách cấm. Ngoại lệ chưa biết rơi vào nhánh mặc định và bị giấu — nên thêm một loại ngoại lệ mới sau này cũng không vô tình rò rỉ gì.

Kiểm tra dự án của bạn:

bash
grep -rn "ex.ToString()\|ex.StackTrace\|exception.Message" --include=*.cs src/ \
  | grep -iv "log\|logger"

Mỗi kết quả là một chỗ cần rà: thông tin đó đi tới client hay chỉ vào log?

Bài 3 — Chi phí của ngoại lệ trong luồng thường ​

Viết hai endpoint "không tìm thấy", một ném NotFoundException và một trả NotFound(). Bắn 10.000 request vào mỗi cái và so sánh độ trễ phân vị 99.

Tiêu chí hoàn thành: bạn có hai con số và một kết luận cân bằng về việc khi nào chi phí đó đáng quan tâm.

Gợi ý và lời giải — Bài 3

Gợi ý. Bài này nối tiếp phép đo ở bài 4.9, nơi ngoại lệ chậm hơn TryParse 778 lần. Nhưng ở đây ngăn xếp sâu hơn nhiều, nên chi phí cũng khác.

Lời giải.

csharp
app.MapGet("/nem/{id:int}", (int id) =>
{
    var kh = _repo.Lay(id);
    if (kh is null) throw new NotFoundException($"Không tìm thấy khách hàng {id}");
    return Results.Ok(kh);
});

app.MapGet("/tra/{id:int}", (int id) =>
{
    var kh = _repo.Lay(id);
    return kh is null ? Results.NotFound() : Results.Ok(kh);
});
bash
bombardier -c 50 -n 10000 http://localhost:5000/nem/99999
bombardier -c 50 -n 10000 http://localhost:5000/tra/99999

Kết quả điển hình:

Ném ngoại lệTrả NotFound()
Thông lượngthấp hơn rõ rệtcao hơn
Độ trễ trung vịcao hơn vài trăm micro-giâythấp
Độ trễ phân vị 99cao hơn đáng kểổn định

Chênh lệch ở phân vị 99 lớn hơn ở trung vị — vì ném ngoại lệ tạo thêm cấp phát bộ nhớ, và những lần thu gom rác kéo theo rơi vào phần đuôi của phân bố.

Vì sao ngăn xếp sâu làm chi phí tăng. Khi ném trong một endpoint ASP.NET Core, runtime phải đi ngược qua toàn bộ chuỗi middleware để tìm khối catch — thường là hàng chục khung ngăn xếp, so với vài khung trong một vòng lặp đơn giản.

Kết luận cân bằng — đây là phần chính của bài. Đừng kết luận "không bao giờ dùng ngoại lệ". Hãy hỏi: tình huống này có thường xuyên không?

Tình huốngTần suấtNên dùng
Tra một mã khách hàng người dùng gõ taySai thường xuyênTrả NotFound()
Truy cập tài nguyên đã bị xoáThỉnh thoảngTrả NotFound()
Mất kết nối databaseHiếmNgoại lệ
Lỗi lập trình, trạng thái không thể xảy raKhông bao giờ nên xảy raNgoại lệ

Ngưỡng thực dụng. Nếu một tình huống xảy ra ở hơn 1% số request, hãy biểu diễn nó bằng giá trị trả về chứ đừng ném ngoại lệ. Dưới ngưỡng đó, chi phí không đáng kể so với một truy vấn database, và tính rõ ràng của code quan trọng hơn.

Mẫu Result<T> giữ được cả hai:

csharp
var result = await _service.GetAsync(id, ct);

return result.Kind switch
{
    ResultKind.Success       => Results.Ok(result.Value),
    ResultKind.NotFound      => Results.NotFound(),
    ResultKind.RuleViolation => Results.BadRequest(result.Error),
    _ => Results.StatusCode(500)
};

Không ném ngoại lệ cho luồng bình thường, nhưng vẫn có xử lý ngoại lệ toàn cục làm lưới an toàn cho những gì thật sự bất thường.

Một điều đáng đo hơn cả tốc độ: ảnh hưởng tới log và cảnh báo. Mỗi ngoại lệ ném ra thường sinh một dòng log mức Error. Một endpoint trả "không tìm thấy" 10.000 lần mỗi ngày sẽ tạo 10.000 dòng lỗi giả, làm ngập log thật và khiến cảnh báo mất giá trị — đúng vấn đề đã bàn ở bài 2.4 về mã trạng thái.

Tự kiểm tra ​

IExceptionHandler giải quyết vấn đề gì?

Nó gom việc ánh xạ exception sang mã HTTP về một nơi, nên bạn khai báo một lần rằng NotFoundException là 404 và ValidationException là 422, rồi mọi action đều đúng. Không có nó thì mỗi controller tự try catch theo một kiểu và client nhận mỗi chỗ một định dạng lỗi khác nhau.

Nhiều IExceptionHandler chạy theo thứ tự nào?

Theo thứ tự đăng ký, và dừng ở handler đầu tiên trả về true. Trả false nghĩa là không phải việc của tôi, chuyển tiếp. Handler bắt tất cả phải đăng ký cuối cùng; nếu không handler nào trả true thì ASP.NET Core trả 500 mặc định.

Vì sao không trả stack trace cho client?

Vì nó tiết lộ đường dẫn file nguồn trên máy build, namespace và cấu trúc dự án, tên cùng phiên bản thư viện kèm CVE đã biết. Thông điệp exception cũng nguy hiểm: một SqlException có thể lộ tên user database và tên máy chủ nội bộ. Client chỉ nên nhận title, status và traceId.

Có nên dùng exception cho trường hợp không tìm thấy không?

Không, với thứ xảy ra thường xuyên thì nên trả về kết quả rỗng rồi để controller trả NotFound. Ném exception tốn kém hơn trả về kết quả khoảng vài trăm lần và có thể nhìn thấy được trên biểu đồ latency. Exception dành cho những gì thật sự bất thường.

UseExceptionHandler không bắt được những gì?

Bốn thứ. Exception trong middleware đăng ký trước nó, nên nó phải đứng đầu pipeline. Exception sau khi response đã bắt đầu, vì không đổi được status code nữa. Exception trong BackgroundService vì nó không đi qua pipeline HTTP. Và async void, vì exception đi thẳng lên và làm sập tiến trình.

Nên log exception ở mức nào?

Phân theo mã trạng thái: từ 500 trở lên là sự cố của bạn nên log mức Error, còn 404 và 422 là hành vi bình thường nên log mức Warning. Log tất cả ở mức Error khiến cảnh báo mất ý nghĩa và không ai còn nhìn dashboard nữa.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. UseExceptionHandler() đứng đầu pipeline — nó chỉ bắt được những lớp bên trong nó.
  2. Log đầy đủ ở server, trả title + status + traceId cho client.
  3. Exception không dành cho luồng thông thường. "Không tìm thấy" nên là một giá trị trả về.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.