/src/main/java/org/kohsuke/github/GHRateLimit.java
Java | 635 lines | 281 code | 63 blank | 291 comment | 65 complexity | 18a2499363efeaba22cdad505e35833b MD5 | raw file
1package org.kohsuke.github; 2 3import com.fasterxml.jackson.annotation.JacksonInject; 4import com.fasterxml.jackson.annotation.JsonCreator; 5import com.fasterxml.jackson.annotation.JsonProperty; 6import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 7import org.apache.commons.lang3.StringUtils; 8import org.kohsuke.github.connector.GitHubConnectorResponse; 9 10import java.time.Duration; 11import java.time.ZonedDateTime; 12import java.time.format.DateTimeFormatter; 13import java.time.format.DateTimeParseException; 14import java.util.Date; 15import java.util.Objects; 16import java.util.concurrent.atomic.AtomicReference; 17import java.util.logging.Logger; 18 19import javax.annotation.CheckForNull; 20import javax.annotation.Nonnull; 21 22import static java.util.logging.Level.FINEST; 23 24/** 25 * Rate limit. 26 * 27 * @author Kohsuke Kawaguchi 28 */ 29@SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD", justification = "JSON API") 30public class GHRateLimit { 31 32 /** 33 * Remaining calls that can be made. 34 * 35 * @deprecated This field should never have been made public. Use {@link #getRemaining()} 36 */ 37 @Deprecated 38 public int remaining; 39 40 /** 41 * Allotted API call per hour. 42 * 43 * @deprecated This field should never have been made public. Use {@link #getLimit()} 44 */ 45 @Deprecated 46 public int limit; 47 48 /** 49 * The time at which the current rate limit window resets in UTC epoch seconds. WARNING: this field was implemented 50 * using {@link Date#Date(long)} which expects UTC epoch milliseconds, so this Date instance is meaningless as a 51 * date. To use this field in any meaningful way, it must be converted to a long using {@link Date#getTime()} 52 * multiplied by 1000. 53 * 54 * @deprecated This field should never have been made public. Use {@link #getResetDate()} 55 */ 56 @Deprecated 57 public Date reset; 58 59 @Nonnull 60 private final Record core; 61 62 @Nonnull 63 private final Record search; 64 65 @Nonnull 66 private final Record graphql; 67 68 @Nonnull 69 private final Record integrationManifest; 70 71 /** 72 * The default GHRateLimit provided to new {@link GitHubClient}s. 73 * 74 * Contains all expired records that will cause {@link GitHubClient#rateLimit(RateLimitTarget)} to refresh with new 75 * data when called. 76 * 77 * Private, but made internal for testing. 78 */ 79 @Nonnull 80 static final GHRateLimit DEFAULT = new GHRateLimit(UnknownLimitRecord.DEFAULT, 81 UnknownLimitRecord.DEFAULT, 82 UnknownLimitRecord.DEFAULT, 83 UnknownLimitRecord.DEFAULT); 84 85 /** 86 * Creates a new {@link GHRateLimit} from a single record for the specified endpoint with place holders for other 87 * records. 88 * 89 * This is used to create {@link GHRateLimit} instances that can merged with other instances. 90 * 91 * @param record 92 * the rate limit record. Can be a regular {@link Record} constructed from header information or an 93 * {@link UnknownLimitRecord} placeholder. 94 * @param rateLimitTarget 95 * which rate limit record to fill 96 * @return a new {@link GHRateLimit} instance containing the supplied record 97 */ 98 @Nonnull 99 static GHRateLimit fromRecord(@Nonnull Record record, @Nonnull RateLimitTarget rateLimitTarget) { 100 if (rateLimitTarget == RateLimitTarget.CORE || rateLimitTarget == RateLimitTarget.NONE) { 101 return new GHRateLimit(record, 102 UnknownLimitRecord.DEFAULT, 103 UnknownLimitRecord.DEFAULT, 104 UnknownLimitRecord.DEFAULT); 105 } else if (rateLimitTarget == RateLimitTarget.SEARCH) { 106 return new GHRateLimit(UnknownLimitRecord.DEFAULT, 107 record, 108 UnknownLimitRecord.DEFAULT, 109 UnknownLimitRecord.DEFAULT); 110 } else if (rateLimitTarget == RateLimitTarget.GRAPHQL) { 111 return new GHRateLimit(UnknownLimitRecord.DEFAULT, 112 UnknownLimitRecord.DEFAULT, 113 record, 114 UnknownLimitRecord.DEFAULT); 115 } else if (rateLimitTarget == RateLimitTarget.INTEGRATION_MANIFEST) { 116 return new GHRateLimit(UnknownLimitRecord.DEFAULT, 117 UnknownLimitRecord.DEFAULT, 118 UnknownLimitRecord.DEFAULT, 119 record); 120 } else { 121 throw new IllegalArgumentException("Unknown rate limit target: " + rateLimitTarget.toString()); 122 } 123 } 124 125 @JsonCreator 126 GHRateLimit(@Nonnull @JsonProperty("core") Record core, 127 @Nonnull @JsonProperty("search") Record search, 128 @Nonnull @JsonProperty("graphql") Record graphql, 129 @Nonnull @JsonProperty("integration_manifest") Record integrationManifest) { 130 // The Nonnull annotation is ignored by Jackson, we have to check manually 131 Objects.requireNonNull(core); 132 Objects.requireNonNull(search); 133 Objects.requireNonNull(graphql); 134 Objects.requireNonNull(integrationManifest); 135 136 this.core = core; 137 this.search = search; 138 this.graphql = graphql; 139 this.integrationManifest = integrationManifest; 140 141 // Deprecated fields 142 this.remaining = core.getRemaining(); 143 this.limit = core.getLimit(); 144 // This is wrong but is how this was implemented. Kept for backward compat. 145 this.reset = new Date(core.getResetEpochSeconds()); 146 } 147 148 /** 149 * Returns the date at which the Core API rate limit will reset. 150 * 151 * @return the calculated date at which the rate limit has or will reset. 152 */ 153 @Nonnull 154 public Date getResetDate() { 155 return getCore().getResetDate(); 156 } 157 158 /** 159 * Gets the remaining number of Core APIs requests allowed before this connection will be throttled. 160 * 161 * @return an integer 162 * @since 1.100 163 */ 164 public int getRemaining() { 165 return getCore().getRemaining(); 166 } 167 168 /** 169 * Gets the total number of Core API calls per hour allotted for this connection. 170 * 171 * @return an integer 172 * @since 1.100 173 */ 174 public int getLimit() { 175 return getCore().getLimit(); 176 } 177 178 /** 179 * Gets the time in epoch seconds when the Core API rate limit will reset. 180 * 181 * @return a long 182 * @since 1.100 183 */ 184 public long getResetEpochSeconds() { 185 return getCore().getResetEpochSeconds(); 186 } 187 188 /** 189 * Whether the reset date for the Core API rate limit has passed. 190 * 191 * @return true if the rate limit reset date has passed. Otherwise false. 192 * @since 1.100 193 */ 194 public boolean isExpired() { 195 return getCore().isExpired(); 196 } 197 198 /** 199 * The core object provides the rate limit status for all non-search-related resources in the REST API. 200 * 201 * @return a rate limit record 202 * @since 1.100 203 */ 204 @Nonnull 205 public Record getCore() { 206 return core; 207 } 208 209 /** 210 * The search record provides the rate limit status for the Search API. 211 * 212 * @return a rate limit record 213 * @since 1.115 214 */ 215 @Nonnull 216 public Record getSearch() { 217 return search; 218 } 219 220 /** 221 * The graphql record provides the rate limit status for the GraphQL API. 222 * 223 * @return a rate limit record 224 * @since 1.115 225 */ 226 @Nonnull 227 public Record getGraphQL() { 228 return graphql; 229 } 230 231 /** 232 * The integration manifest record provides the rate limit status for the GitHub App Manifest code conversion 233 * endpoint. 234 * 235 * @return a rate limit record 236 * @since 1.115 237 */ 238 @Nonnull 239 public Record getIntegrationManifest() { 240 return integrationManifest; 241 } 242 243 @Override 244 public String toString() { 245 return "GHRateLimit {" + "core " + getCore().toString() + ", search " + getSearch().toString() + ", graphql " 246 + getGraphQL().toString() + ", integrationManifest " + getIntegrationManifest().toString() + "}"; 247 } 248 249 @Override 250 public boolean equals(Object o) { 251 if (this == o) { 252 return true; 253 } 254 if (o == null || getClass() != o.getClass()) { 255 return false; 256 } 257 GHRateLimit rateLimit = (GHRateLimit) o; 258 return getCore().equals(rateLimit.getCore()) && getSearch().equals(rateLimit.getSearch()) 259 && getGraphQL().equals(rateLimit.getGraphQL()) 260 && getIntegrationManifest().equals(rateLimit.getIntegrationManifest()); 261 } 262 263 @Override 264 public int hashCode() { 265 return Objects.hash(getCore(), getSearch(), getGraphQL(), getIntegrationManifest()); 266 } 267 268 /** 269 * Merge a {@link GHRateLimit} with another one to create a new {@link GHRateLimit} keeping the latest 270 * {@link Record}s from each. 271 * 272 * @param newLimit 273 * {@link GHRateLimit} with potentially updated {@link Record}s. 274 * @return a merged {@link GHRateLimit} with the latest {@link Record}s from these two instances. If the merged 275 * instance is equal to the current instance, the current instance is returned. 276 */ 277 @Nonnull 278 GHRateLimit getMergedRateLimit(@Nonnull GHRateLimit newLimit) { 279 280 GHRateLimit merged = new GHRateLimit(getCore().currentOrUpdated(newLimit.getCore()), 281 getSearch().currentOrUpdated(newLimit.getSearch()), 282 getGraphQL().currentOrUpdated(newLimit.getGraphQL()), 283 getIntegrationManifest().currentOrUpdated(newLimit.getIntegrationManifest())); 284 285 if (merged.equals(this)) { 286 merged = this; 287 } 288 289 return merged; 290 } 291 292 /** 293 * Gets the specified {@link Record}. 294 * 295 * {@link RateLimitTarget#NONE} will return {@link UnknownLimitRecord#DEFAULT} to prevent any clients from 296 * accidentally waiting on that record to reset before continuing. 297 * 298 * @param rateLimitTarget 299 * the target rate limit record 300 * @return the target {@link Record} from this instance. 301 */ 302 @Nonnull 303 Record getRecord(@Nonnull RateLimitTarget rateLimitTarget) { 304 if (rateLimitTarget == RateLimitTarget.CORE) { 305 return getCore(); 306 } else if (rateLimitTarget == RateLimitTarget.SEARCH) { 307 return getSearch(); 308 } else if (rateLimitTarget == RateLimitTarget.GRAPHQL) { 309 return getGraphQL(); 310 } else if (rateLimitTarget == RateLimitTarget.INTEGRATION_MANIFEST) { 311 return getIntegrationManifest(); 312 } else if (rateLimitTarget == RateLimitTarget.NONE) { 313 return UnknownLimitRecord.DEFAULT; 314 } else { 315 throw new IllegalArgumentException("Unknown rate limit target: " + rateLimitTarget.toString()); 316 } 317 } 318 319 /** 320 * A limit record used as a placeholder when the the actual limit is not known. 321 * 322 * @since 1.100 323 */ 324 public static class UnknownLimitRecord extends Record { 325 326 private static final long defaultUnknownLimitResetSeconds = Duration.ofSeconds(30).getSeconds(); 327 328 /** 329 * The number of seconds until a {@link UnknownLimitRecord} will expire. 330 * 331 * This is set to a somewhat short duration, rather than a long one. This avoids 332 * {@link {@link GitHubClient#rateLimit(RateLimitTarget)}} requesting rate limit updates continuously, but also 333 * avoids holding on to stale unknown records indefinitely. 334 * 335 * When merging {@link GHRateLimit} instances, {@link UnknownLimitRecord}s will be superseded by incoming 336 * regular {@link Record}s. 337 * 338 * @see GHRateLimit#getMergedRateLimit(GHRateLimit) 339 */ 340 static long unknownLimitResetSeconds = defaultUnknownLimitResetSeconds; 341 342 static final int unknownLimit = 1000000; 343 static final int unknownRemaining = 999999; 344 345 // The default UnknownLimitRecord is an expired record. 346 private static final UnknownLimitRecord DEFAULT = new UnknownLimitRecord(Long.MIN_VALUE); 347 348 // The starting current UnknownLimitRecord is an expired record. 349 private static final AtomicReference<UnknownLimitRecord> current = new AtomicReference<>(DEFAULT); 350 351 /** 352 * Create a new unknown record that resets at the specified time. 353 * 354 * @param resetEpochSeconds 355 * the epoch second time when this record will expire. 356 */ 357 private UnknownLimitRecord(long resetEpochSeconds) { 358 super(unknownLimit, unknownRemaining, resetEpochSeconds); 359 } 360 361 static Record current() { 362 Record result = current.get(); 363 if (result.isExpired()) { 364 current.set(new UnknownLimitRecord(System.currentTimeMillis() / 1000L + unknownLimitResetSeconds)); 365 result = current.get(); 366 } 367 return result; 368 } 369 370 /** 371 * Reset the current UnknownLimitRecord. For use during testing only. 372 */ 373 static void reset() { 374 current.set(DEFAULT); 375 unknownLimitResetSeconds = defaultUnknownLimitResetSeconds; 376 } 377 } 378 379 /** 380 * A rate limit record. 381 * 382 * @since 1.100 383 */ 384 public static class Record { 385 /** 386 * Remaining calls that can be made. 387 */ 388 private final int remaining; 389 390 /** 391 * Allotted API call per time period. 392 */ 393 private final int limit; 394 395 /** 396 * The time at which the current rate limit window resets in UTC epoch seconds. 397 */ 398 private final long resetEpochSeconds; 399 400 /** 401 * EpochSeconds time (UTC) at which this instance was created. 402 */ 403 private final long createdAtEpochSeconds = System.currentTimeMillis() / 1000; 404 405 /** 406 * The date at which the rate limit will reset, adjusted to local machine time if the local machine's clock not 407 * synchronized with to the same clock as the GitHub server. 408 * 409 * @see #calculateResetDate(String) 410 * @see #getResetDate() 411 */ 412 @Nonnull 413 private final Date resetDate; 414 415 /** 416 * Instantiates a new Record. 417 * 418 * @param limit 419 * the limit 420 * @param remaining 421 * the remaining 422 * @param resetEpochSeconds 423 * the reset epoch seconds 424 */ 425 public Record(@JsonProperty(value = "limit", required = true) int limit, 426 @JsonProperty(value = "remaining", required = true) int remaining, 427 @JsonProperty(value = "reset", required = true) long resetEpochSeconds) { 428 this(limit, remaining, resetEpochSeconds, null); 429 } 430 431 /** 432 * Instantiates a new Record. Called by Jackson data binding or during header parsing. 433 * 434 * @param limit 435 * the limit 436 * @param remaining 437 * the remaining 438 * @param resetEpochSeconds 439 * the reset epoch seconds 440 * @param connectorResponse 441 * the response info 442 */ 443 @JsonCreator 444 Record(@JsonProperty(value = "limit", required = true) int limit, 445 @JsonProperty(value = "remaining", required = true) int remaining, 446 @JsonProperty(value = "reset", required = true) long resetEpochSeconds, 447 @JacksonInject @CheckForNull GitHubConnectorResponse connectorResponse) { 448 this.limit = limit; 449 this.remaining = remaining; 450 this.resetEpochSeconds = resetEpochSeconds; 451 String updatedAt = null; 452 if (connectorResponse != null) { 453 updatedAt = connectorResponse.header("Date"); 454 } 455 this.resetDate = calculateResetDate(updatedAt); 456 } 457 458 /** 459 * Determine if the current {@link Record} is outdated compared to another. Rate Limit dates are only accurate 460 * to the second, so we look at other information in the record as well. 461 * 462 * {@link Record}s with earlier {@link #getResetEpochSeconds()} are replaced by those with later. 463 * {@link Record}s with the same {@link #getResetEpochSeconds()} are replaced by those with less remaining 464 * count. 465 * 466 * {@link UnknownLimitRecord}s compare with each other like regular {@link Record}s. 467 * 468 * {@link Record}s are replaced by {@link UnknownLimitRecord}s only when the current {@link Record} is expired 469 * and the {@link UnknownLimitRecord} is not. Otherwise Regular {@link Record}s are not replaced by 470 * {@link UnknownLimitRecord}s. 471 * 472 * Expiration is only considered after other checks, meaning expired records may sometimes be replaced by other 473 * expired records. 474 * 475 * @param other 476 * the other {@link Record} 477 * @return the {@link Record} that is most current 478 */ 479 Record currentOrUpdated(@Nonnull Record other) { 480 // This set of checks avoids most calls to isExpired() 481 // Depends on UnknownLimitRecord.current() to prevent continuous updating of GHRateLimit rateLimit() 482 if (getResetEpochSeconds() > other.getResetEpochSeconds() 483 || (getResetEpochSeconds() == other.getResetEpochSeconds() 484 && getRemaining() <= other.getRemaining())) { 485 // If the current record has a later reset 486 // or the current record has the same reset and fewer or same requests remaining 487 // Then it is most recent 488 return this; 489 } else if (!(other instanceof UnknownLimitRecord)) { 490 // If the above is not the case that means other has a later reset 491 // or the same resent and fewer requests remaining. 492 // If the other record is not an unknown record, the the other is more recent 493 return other; 494 } else if (this.isExpired() && !other.isExpired()) { 495 // The other is an unknown record. 496 // If the current record has expired and the other hasn't, return the other. 497 return other; 498 } 499 500 // If none of the above, the current record is most valid. 501 return this; 502 } 503 504 /** 505 * Recalculates the {@link #resetDate} relative to the local machine clock. 506 * <p> 507 * {@link RateLimitChecker}s and {@link RateLimitHandler}s use {@link #getResetDate()} to make decisions about 508 * how long to wait for until for the rate limit to reset. That means that {@link #getResetDate()} needs to be 509 * calculated based on the local machine clock. 510 * </p> 511 * <p> 512 * When we say that the clock on two machines is "synchronized", we mean that the UTC time returned from 513 * {@link System#currentTimeMillis()} on each machine is basically the same. For the purposes of rate limits an 514 * differences of up to a second can be ignored. 515 * </p> 516 * <p> 517 * When the clock on the local machine is synchronized to the same time as the clock on the GitHub server (via a 518 * time service for example), the {@link #resetDate} generated directly from {@link #resetEpochSeconds} will be 519 * accurate for the local machine as well. 520 * </p> 521 * <p> 522 * When the clock on the local machine is not synchronized with the server, the {@link #resetDate} must be 523 * recalculated relative to the local machine clock. This is done by taking the number of seconds between the 524 * response "Date" header and {@link #resetEpochSeconds} and then adding that to this record's 525 * {@link #createdAtEpochSeconds}. 526 * 527 * @param updatedAt 528 * a string date in RFC 1123 529 * @return reset date based on the passed date 530 */ 531 @Nonnull 532 private Date calculateResetDate(@CheckForNull String updatedAt) { 533 long updatedAtEpochSeconds = createdAtEpochSeconds; 534 if (!StringUtils.isBlank(updatedAt)) { 535 try { 536 // Get the server date and reset data, will always return a time in GMT 537 updatedAtEpochSeconds = ZonedDateTime.parse(updatedAt, DateTimeFormatter.RFC_1123_DATE_TIME) 538 .toEpochSecond(); 539 } catch (DateTimeParseException e) { 540 if (LOGGER.isLoggable(FINEST)) { 541 LOGGER.log(FINEST, "Malformed Date header value " + updatedAt, e); 542 } 543 } 544 } 545 546 // This may seem odd but it results in an accurate or slightly pessimistic reset date 547 // based on system time rather than assuming the system time synchronized with the server 548 long calculatedSecondsUntilReset = resetEpochSeconds - updatedAtEpochSeconds; 549 return new Date((createdAtEpochSeconds + calculatedSecondsUntilReset) * 1000); 550 } 551 552 /** 553 * Gets the remaining number of requests allowed before this connection will be throttled. 554 * 555 * @return an integer 556 */ 557 public int getRemaining() { 558 return remaining; 559 } 560 561 /** 562 * Gets the total number of API calls per hour allotted for this connection. 563 * 564 * @return an integer 565 */ 566 public int getLimit() { 567 return limit; 568 } 569 570 /** 571 * Gets the time in epoch seconds when the rate limit will reset. 572 * 573 * This is the raw value returned by the server. This value is not adjusted if local machine time is not 574 * synchronized with server time. If attempting to check when the rate limit will reset, use 575 * {@link #getResetDate()} or implement a {@link RateLimitChecker} instead. 576 * 577 * @return a long representing the time in epoch seconds when the rate limit will reset 578 * @see #getResetDate() 579 */ 580 public long getResetEpochSeconds() { 581 return resetEpochSeconds; 582 } 583 584 /** 585 * Whether the rate limit reset date indicated by this instance is expired 586 * 587 * If attempting to wait for the rate limit to reset, consider implementing a {@link RateLimitChecker} instead. 588 * 589 * @return true if the rate limit reset date has passed. Otherwise false. 590 */ 591 public boolean isExpired() { 592 return getResetDate().getTime() < System.currentTimeMillis(); 593 } 594 595 /** 596 * The date at which the rate limit will reset, adjusted to local machine time if the local machine's clock not 597 * synchronized with to the same clock as the GitHub server. 598 * 599 * If attempting to wait for the rate limit to reset, consider implementing a {@link RateLimitChecker} instead. 600 * 601 * @return the calculated date at which the rate limit has or will reset. 602 */ 603 @Nonnull 604 public Date getResetDate() { 605 return new Date(resetDate.getTime()); 606 } 607 608 @Override 609 public String toString() { 610 return "{" + "remaining=" + getRemaining() + ", limit=" + getLimit() + ", resetDate=" + getResetDate() 611 + '}'; 612 } 613 614 @Override 615 public boolean equals(Object o) { 616 if (this == o) { 617 return true; 618 } 619 if (o == null || getClass() != o.getClass()) { 620 return false; 621 } 622 Record record = (Record) o; 623 return getRemaining() == record.getRemaining() && getLimit() == record.getLimit() 624 && getResetEpochSeconds() == record.getResetEpochSeconds() 625 && getResetDate().equals(record.getResetDate()); 626 } 627 628 @Override 629 public int hashCode() { 630 return Objects.hash(getRemaining(), getLimit(), getResetEpochSeconds(), getResetDate()); 631 } 632 } 633 634 private static final Logger LOGGER = Logger.getLogger(Requester.class.getName()); 635}