SeibroHttp.cs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. using System.Text;
  2. using Microsoft.Extensions.Logging;
  3. namespace Infrastructure.StockData;
  4. /// <summary>
  5. /// SEIBro OpenAPI(seibro.or.kr/OpenPlatform/callOpenAPI.jsp) 호출 공통 — query param key 인증 + 간단 3회 재시도 (2s/4s 백오프).
  6. /// GET {BaseUrl}?key={ApiKey}&amp;apiId={apiId}&amp;params={K:V,K2:V2}
  7. /// • params 는 콜론(:)/콤마(,) 구분 단일 문자열 — SEIBro 비표준 규격이라 구분자(콜론·콤마)는 raw 로 보내고,
  8. /// 키·값만 개별 Uri.EscapeDataString 처리한다. 근거: KRX(KrxCoKrHttp)는 값이 전부 코드/날짜라 raw 조립이지만
  9. /// SEIBro 도 값이 코드/날짜/ISIN 이라 escape 는 사실상 no-op — 다만 향후 예약문자(&amp; 공백 등)가 값에 들어와도
  10. /// query 가 깨지지 않도록 방어한다. 구분자까지 escape 하면(%3A/%2C) SEIBro 가 params 를 못 읽으므로 절대 금지 (docs/SEIBro/readme.md §1).
  11. /// • https 연결 실패(재시도 소진) 시 http 로 1회 fallback — SEIBro 가이드 원문이 http 엔드포인트라 환경에 따라 https 미지원 가능성 대비.
  12. /// • 응답 Content-Type charset 감지 디코딩 — EUC-KR 가능성 대비 CodePagesEncodingProvider 등록
  13. /// (.NET 에서 euc-kr 등 코드페이지 인코딩은 기본 미등록 — System.Text.Encoding.CodePages 는 shared framework 포함).
  14. /// • 로그에 인증 key 는 절대 남기지 않는다 — apiId·params 만 기록 (KrxCoKrHttp 의 key 비로깅 정책과 동일).
  15. /// </summary>
  16. internal static class SeibroHttp
  17. {
  18. public const string ClientName = "Seibro";
  19. private const int MaxAttempts = 3;
  20. static SeibroHttp()
  21. {
  22. // euc-kr/ks_c_5601-1987 등 한국어 코드페이지 디코딩 지원 (idempotent)
  23. Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
  24. }
  25. /// <summary>호출 URL 조립 — key 는 마지막에 붙여 로그용 문자열과 분리하기 쉽게 한다. 값 단위 escape, 구분자 raw.</summary>
  26. public static string BuildParams(IReadOnlyList<KeyValuePair<string, string>> parameters)
  27. {
  28. return string.Join(',', parameters.Select(c => $"{Uri.EscapeDataString(c.Key)}:{Uri.EscapeDataString(c.Value)}"));
  29. }
  30. public static async Task<string> GetStringWithRetryAsync(HttpClient client, string baseUrl, string apiKey, string apiId, IReadOnlyList<KeyValuePair<string, string>> parameters, ILogger logger, CancellationToken ct)
  31. {
  32. var paramString = BuildParams(parameters);
  33. var url = $"{baseUrl}?key={Uri.EscapeDataString(apiKey)}&apiId={Uri.EscapeDataString(apiId)}&params={paramString}";
  34. try
  35. {
  36. return await GetWithRetryAsync(client, url, apiId, paramString, logger, ct);
  37. }
  38. catch (HttpRequestException ex) when (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
  39. {
  40. // https 연결 불가 → http 로 1회 fallback (공개 데이터 + 재시도 소진 후 최후 수단)
  41. var httpUrl = string.Concat("http://", url.AsSpan("https://".Length));
  42. logger.LogWarning(ex, "[Seibro] https 실패 — http fallback 1회 시도 (apiId={ApiId}, params={Params})", apiId, paramString);
  43. using var request = new HttpRequestMessage(HttpMethod.Get, httpUrl);
  44. using var response = await client.SendAsync(request, ct);
  45. response.EnsureSuccessStatusCode();
  46. return await ReadContentAsync(response, logger, ct);
  47. }
  48. }
  49. private static async Task<string> GetWithRetryAsync(HttpClient client, string url, string apiId, string paramString, ILogger logger, CancellationToken ct)
  50. {
  51. for (var attempt = 1; ; attempt++)
  52. {
  53. try
  54. {
  55. using var request = new HttpRequestMessage(HttpMethod.Get, url);
  56. using var response = await client.SendAsync(request, ct);
  57. response.EnsureSuccessStatusCode();
  58. return await ReadContentAsync(response, logger, ct);
  59. }
  60. catch (Exception ex) when (ex is not OperationCanceledException && attempt < MaxAttempts)
  61. {
  62. // key 마스킹: URL 대신 apiId/params 만 로깅
  63. logger.LogWarning(ex, "[Seibro] HTTP 실패 — 재시도 {Attempt}/{Max} (apiId={ApiId}, params={Params})", attempt, MaxAttempts, apiId, paramString);
  64. await Task.Delay(TimeSpan.FromSeconds(2 * attempt), ct);
  65. }
  66. }
  67. }
  68. /// <summary>Content-Type charset 감지 디코딩 — charset 미표기/미지원이면 UTF-8 (샘플 실응답 기준 기본값)</summary>
  69. private static async Task<string> ReadContentAsync(HttpResponseMessage response, ILogger logger, CancellationToken ct)
  70. {
  71. var bytes = await response.Content.ReadAsByteArrayAsync(ct);
  72. var charset = response.Content.Headers.ContentType?.CharSet?.Trim('"', '\'');
  73. var encoding = Encoding.UTF8;
  74. if (!string.IsNullOrWhiteSpace(charset))
  75. {
  76. try
  77. {
  78. encoding = Encoding.GetEncoding(charset);
  79. }
  80. catch (ArgumentException)
  81. {
  82. logger.LogWarning("[Seibro] 미지원 charset '{Charset}' — UTF-8 로 디코딩", charset);
  83. }
  84. }
  85. return encoding.GetString(bytes);
  86. }
  87. }